diff --git a/CHANGELOG.md b/CHANGELOG.md index b432da7..6259e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ All notable changes to Armature are documented here. +## v0.9.4 — Package Adoption + Roles + +Packages can now declare `adoptable: true` in their manifest. When a team +adopts an adoptable package, a team-scoped copy is created that references +the original (shared assets, no disk duplication). The package's +`requires_roles` auto-populate into a new `team_role_catalog` table so +team admins know which roles to assign. + +**Schema** + +- Migration 017: `adoptable` and `adopted_from` columns on `packages`. + `team_role_catalog` table (team_id, role, source_package_id) with + unique constraint. Both Postgres and SQLite dialects. + +**Store + Models** + +- `PackageRegistration`: `Adoptable bool`, `AdoptedFrom *string`. +- `PackageStore`: `ListAdoptable()`, `GetByAdoptedFrom()`. +- `TeamRoleCatalogEntry` model struct. +- `TeamStore`: `AddRoleToCatalog`, `ListRoleCatalog`, + `RemoveRoleCatalogBySource`. + +**Handlers** + +- `POST /teams/:teamId/packages/:id/adopt` — adopt a global adoptable + package into the team. Creates team-scoped registration, clones + workflow if applicable, populates role catalog. Idempotent. +- `GET /teams/:teamId/packages/adoptable` — list available packages + with adoption status per team. +- `DELETE /teams/:teamId/packages/:id/unadopt` — remove adopted package + and clean up role catalog entries. +- `GET /teams/:teamId/roles/catalog` — list known roles for a team. + +**Manifest Validation** + +- `adoptable: true` parsed as `ManifestInfo.Adoptable`. +- Rejected on `library` and `test-runner` types. +- Persisted to DB on package install. + +**Deprecation** + +- `POST /teams/:teamId/workflows/:id/adopt` (`AdoptTeamWorkflow`) now + returns `X-Deprecated` header and logs a deprecation warning. + Use the package-level adoption endpoint instead. + +**Tests** + +- 11 new tests: 5 manifest validation, 3 role catalog store, 3 handler + integration (adopt success, idempotent, non-adoptable rejection). + +--- + ## v0.9.3 — Team User Roles Promotes the team role system from a single-role-per-member model to a diff --git a/ROADMAP.md b/ROADMAP.md index 8fee870..e6deca9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,11 +105,14 @@ Manifest `requires_roles` field (advisory). Starlark `teams` module with `get_member_roles()` and `has_role()`. Team-admin UI with role badge chips and assignment dropdown. 10 new tests. -**v0.9.4 — Package Adoption + Roles** +**v0.9.4 — Package Adoption + Roles** *(completed)* -`scope: adoptable` manifest field. When a team adopts an adoptable -package, the package's `requires_roles` auto-populate into the team's -role slots. Replaces `AdoptTeamWorkflow` clone mechanism. +`adoptable` manifest field + `team_role_catalog` table. When a team +adopts an adoptable package, the package's `requires_roles` auto-populate +into the team's role catalog. Adopted packages reference the original via +`adopted_from` column (shared assets, no disk duplication). +`AdoptTeamWorkflow` deprecated in favor of package-level adoption. +4 new endpoints, migration 017, 11 new tests. **v0.9.5 — Typed Forms → SDK Primitive** diff --git a/server/database/migrations/postgres/017_package_adoption.sql b/server/database/migrations/postgres/017_package_adoption.sql new file mode 100644 index 0000000..487347e --- /dev/null +++ b/server/database/migrations/postgres/017_package_adoption.sql @@ -0,0 +1,21 @@ +-- ========================================== +-- Armature — 017 Package Adoption + Role Catalog +-- ========================================== + +-- Add adoptable flag and adoption lineage to packages +ALTER TABLE packages ADD COLUMN IF NOT EXISTS adoptable BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE packages ADD COLUMN IF NOT EXISTS adopted_from TEXT; + +CREATE INDEX IF NOT EXISTS idx_packages_adoptable ON packages(adoptable) WHERE adoptable = true; + +-- Team role catalog: known roles sourced from adopted packages +CREATE TABLE IF NOT EXISTS team_role_catalog ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL, + source_package_id TEXT REFERENCES packages(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(team_id, role) +); + +CREATE INDEX IF NOT EXISTS idx_team_role_catalog_team ON team_role_catalog(team_id); diff --git a/server/database/migrations/sqlite/017_package_adoption.sql b/server/database/migrations/sqlite/017_package_adoption.sql new file mode 100644 index 0000000..66d4c9c --- /dev/null +++ b/server/database/migrations/sqlite/017_package_adoption.sql @@ -0,0 +1,21 @@ +-- ========================================== +-- Armature — 017 Package Adoption + Role Catalog (SQLite) +-- ========================================== + +-- Add adoptable flag and adoption lineage to packages +ALTER TABLE packages ADD COLUMN adoptable INTEGER NOT NULL DEFAULT 0; +ALTER TABLE packages ADD COLUMN adopted_from TEXT; + +CREATE INDEX IF NOT EXISTS idx_packages_adoptable ON packages(adoptable); + +-- Team role catalog: known roles sourced from adopted packages +CREATE TABLE IF NOT EXISTS team_role_catalog ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + role TEXT NOT NULL, + source_package_id TEXT REFERENCES packages(id) ON DELETE SET NULL, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(team_id, role) +); + +CREATE INDEX IF NOT EXISTS idx_team_role_catalog_team ON team_role_catalog(team_id); diff --git a/server/handlers/package_adopt.go b/server/handlers/package_adopt.go new file mode 100644 index 0000000..3f4677d --- /dev/null +++ b/server/handlers/package_adopt.go @@ -0,0 +1,284 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "armature/store" +) + +// PackageAdoptHandler manages team package adoption endpoints. +type PackageAdoptHandler struct { + stores store.Stores +} + +func NewPackageAdoptHandler(s store.Stores) *PackageAdoptHandler { + return &PackageAdoptHandler{stores: s} +} + +// AdoptPackage clones a global adoptable package into the team. +// POST /api/v1/teams/:teamId/packages/:id/adopt +func (h *PackageAdoptHandler) AdoptPackage(c *gin.Context) { + ctx := c.Request.Context() + teamID := c.Param("teamId") + sourceID := c.Param("id") + userID := c.GetString("user_id") + + // Load source package + src, err := h.stores.Packages.Get(ctx, sourceID) + if err != nil || src == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) + return + } + + // Verify adoptable + global + if !src.Adoptable { + c.JSON(http.StatusBadRequest, gin.H{"error": "package is not adoptable"}) + return + } + if src.Scope != "global" { + c.JSON(http.StatusBadRequest, gin.H{"error": "only global packages can be adopted"}) + return + } + + // Idempotency: check if already adopted + existing, err := h.stores.Packages.GetByAdoptedFrom(ctx, sourceID, teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check adoption"}) + return + } + if existing != nil { + // Already adopted — return the existing record + roles, _ := h.stores.Teams.ListRoleCatalog(ctx, teamID) + c.JSON(http.StatusOK, gin.H{ + "package": existing, + "roles_added": filterRolesBySource(roles, existing.ID), + "already_adopted": true, + }) + return + } + + // Generate adopted package ID: {sourceID}--{teamID[:8]} + shortTeam := teamID + if len(shortTeam) > 8 { + shortTeam = shortTeam[:8] + } + adoptedID := sourceID + "--" + shortTeam + + // Create team-scoped package row referencing the original + adoptedPkg := &store.PackageRegistration{ + ID: adoptedID, + Title: src.Title, + Type: src.Type, + Version: src.Version, + Description: src.Description, + Author: src.Author, + Tier: src.Tier, + IsSystem: false, + Scope: "team", + TeamID: &teamID, + InstalledBy: &userID, + Manifest: src.Manifest, + Enabled: true, + Status: "active", + SchemaVersion: src.SchemaVersion, + PackageSettings: src.PackageSettings, + Source: "extension", + Adoptable: false, + AdoptedFrom: &sourceID, + } + + if err := h.stores.Packages.Create(ctx, adoptedPkg); err != nil { + log.Printf("[packages] adopt: failed to create adopted package %s: %v", adoptedID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt package"}) + return + } + + // Auto-populate role catalog from requires_roles + var rolesAdded []string + if rr, ok := src.Manifest["requires_roles"].([]any); ok { + for _, v := range rr { + if role, ok := v.(string); ok && role != "" { + if err := h.stores.Teams.AddRoleToCatalog(ctx, teamID, role, adoptedID); err != nil { + log.Printf("[packages] adopt: failed to add role %q to catalog: %v", role, err) + continue + } + rolesAdded = append(rolesAdded, role) + } + } + } + + // If source is a workflow package, clone the workflow + stages + if src.Type == "workflow" { + h.cloneWorkflowForAdoption(c, src, adoptedPkg) + } + + log.Printf("[packages] adopted %s → %s for team %s (roles: %v)", sourceID, adoptedID, teamID, rolesAdded) + c.JSON(http.StatusCreated, gin.H{ + "package": adoptedPkg, + "roles_added": rolesAdded, + }) +} + +// cloneWorkflowForAdoption copies the workflow definition from the source +// package into a team-scoped workflow. Best-effort — logs errors but does +// not abort the adoption. +func (h *PackageAdoptHandler) cloneWorkflowForAdoption(c *gin.Context, src, adopted *store.PackageRegistration) { + ctx := c.Request.Context() + teamID := *adopted.TeamID + + // Extract workflow_definition from manifest + wfDef, ok := src.Manifest["workflow_definition"].(map[string]any) + if !ok { + log.Printf("[packages] adopt: no workflow_definition in manifest for %s", src.ID) + return + } + + // Use the InstallWorkflowFromManifest path if available, or + // fall back to looking up the workflow by package ID + wfSlug, _ := wfDef["slug"].(string) + if wfSlug == "" { + wfSlug = src.ID + } + + // Find the global workflow installed from this package + wf, err := h.stores.Workflows.GetBySlug(ctx, nil, wfSlug) + if err != nil || wf == nil { + log.Printf("[packages] adopt: source workflow %q not found, skipping clone", wfSlug) + return + } + + stages, err := h.stores.Workflows.ListStages(ctx, wf.ID) + if err != nil { + log.Printf("[packages] adopt: failed to load stages for %s: %v", wf.ID, err) + return + } + + // Clone workflow into team scope + clone := *wf + clone.ID = "" + clone.TeamID = &teamID + clone.IsActive = false + clone.Version = 0 + clone.CreatedBy = c.GetString("user_id") + + if err := h.stores.Workflows.Create(ctx, &clone); err != nil { + // Slug conflict — append short ID + clone.Slug = wfSlug + "-" + store.NewID()[:6] + if err2 := h.stores.Workflows.Create(ctx, &clone); err2 != nil { + log.Printf("[packages] adopt: failed to clone workflow: %v", err2) + return + } + } + + // Clone stages + for _, st := range stages { + cloneSt := st + cloneSt.ID = "" + cloneSt.WorkflowID = clone.ID + if err := h.stores.Workflows.CreateStage(ctx, &cloneSt); err != nil { + log.Printf("[packages] adopt: failed to clone stage %s: %v", st.Name, err) + } + } + log.Printf("[packages] adopt: cloned workflow %s → %s for team %s", wf.ID, clone.ID, teamID) +} + +// ListAdoptablePackages returns global adoptable packages, annotating which +// ones have already been adopted by this team. +// GET /api/v1/teams/:teamId/packages/adoptable +func (h *PackageAdoptHandler) ListAdoptablePackages(c *gin.Context) { + ctx := c.Request.Context() + teamID := c.Param("teamId") + + pkgs, err := h.stores.Packages.ListAdoptable(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"}) + return + } + + type adoptableItem struct { + store.PackageRegistration + Adopted bool `json:"adopted"` + } + + var result []adoptableItem + for _, pkg := range pkgs { + item := adoptableItem{PackageRegistration: pkg} + existing, _ := h.stores.Packages.GetByAdoptedFrom(ctx, pkg.ID, teamID) + item.Adopted = existing != nil + result = append(result, item) + } + if result == nil { + result = []adoptableItem{} + } + c.JSON(http.StatusOK, gin.H{"data": result}) +} + +// UnadoptPackage removes a team's adopted package and cleans up role catalog. +// DELETE /api/v1/teams/:teamId/packages/:id/unadopt +func (h *PackageAdoptHandler) UnadoptPackage(c *gin.Context) { + ctx := c.Request.Context() + teamID := c.Param("teamId") + pkgID := c.Param("id") + + pkg, err := h.stores.Packages.Get(ctx, pkgID) + if err != nil || pkg == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) + return + } + + // Verify it's an adopted package belonging to this team + if pkg.AdoptedFrom == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "package was not adopted"}) + return + } + if pkg.TeamID == nil || *pkg.TeamID != teamID { + c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"}) + return + } + + // Clean up role catalog entries sourced from this package + if err := h.stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgID); err != nil { + log.Printf("[packages] unadopt: failed to clean role catalog: %v", err) + } + + // Delete the adopted package row + if err := h.stores.Packages.Delete(ctx, pkgID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove package"}) + return + } + + log.Printf("[packages] unadopted %s from team %s", pkgID, teamID) + c.JSON(http.StatusOK, gin.H{"message": "package unadopted"}) +} + +// ListRoleCatalog returns all known roles for a team. +// GET /api/v1/teams/:teamId/roles/catalog +func (h *PackageAdoptHandler) ListRoleCatalog(c *gin.Context) { + ctx := c.Request.Context() + teamID := c.Param("teamId") + + roles, err := h.stores.Teams.ListRoleCatalog(ctx, teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list role catalog"}) + return + } + if roles == nil { + roles = []store.TeamRoleCatalogEntry{} + } + c.JSON(http.StatusOK, gin.H{"data": roles}) +} + +// filterRolesBySource returns only roles from a specific package. +func filterRolesBySource(roles []store.TeamRoleCatalogEntry, pkgID string) []string { + var result []string + for _, r := range roles { + if r.SourcePackageID == pkgID { + result = append(result, r.Role) + } + } + return result +} + diff --git a/server/handlers/package_adopt_test.go b/server/handlers/package_adopt_test.go new file mode 100644 index 0000000..e063c77 --- /dev/null +++ b/server/handlers/package_adopt_test.go @@ -0,0 +1,353 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "armature/database" + "armature/store" +) + +// ── Manifest: adoptable parsing ────────────── + +func TestValidateManifest_Adoptable(t *testing.T) { + m := map[string]any{ + "id": "adopt-wf", + "title": "Adoptable Workflow", + "type": "workflow", + "adoptable": true, + "workflow_definition": map[string]any{ + "slug": "adopt-wf", + }, + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.Adoptable { + t.Error("expected Adoptable to be true") + } +} + +func TestValidateManifest_Adoptable_Default(t *testing.T) { + m := map[string]any{ + "id": "plain-surface", + "title": "Plain Surface", + "type": "surface", + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Adoptable { + t.Error("expected Adoptable to be false by default") + } +} + +func TestValidateManifest_Adoptable_LibraryRejected(t *testing.T) { + m := map[string]any{ + "id": "adopt-lib", + "title": "Adoptable Library", + "type": "library", + "adoptable": true, + "exports": []any{"module_a"}, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for adoptable library") + } +} + +func TestValidateManifest_Adoptable_TestRunnerRejected(t *testing.T) { + m := map[string]any{ + "id": "adopt-runner", + "title": "Adoptable Runner", + "type": "test-runner", + "adoptable": true, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for adoptable test-runner") + } +} + +func TestValidateManifest_Adoptable_SurfaceAllowed(t *testing.T) { + m := map[string]any{ + "id": "adopt-surface", + "title": "Adoptable Surface", + "type": "surface", + "adoptable": true, + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.Adoptable { + t.Error("expected Adoptable to be true for surface") + } +} + +// ── Store: role catalog CRUD ────────────── + +// seedMinimalPackage creates a minimal package for FK references in tests. +func seedMinimalPackage(t *testing.T, stores store.Stores, id string) { + t.Helper() + pkg := &store.PackageRegistration{ + ID: id, Title: id, Type: "surface", Version: "1.0.0", + Scope: "global", Tier: "browser", Manifest: map[string]any{"id": id, "title": id}, + Enabled: true, Status: "active", Source: "extension", + } + if err := stores.Packages.Create(context.Background(), pkg); err != nil { + t.Fatalf("seed minimal package %s: %v", id, err) + } +} + +func TestRoleCatalog_AddAndList(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, _, _ := seedTeamAndMember(t, stores) + pkgID := "rc-pkg-" + store.NewID()[:6] + seedMinimalPackage(t, stores, pkgID) + + // Add two roles + if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgID); err != nil { + t.Fatalf("AddRoleToCatalog: %v", err) + } + if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgID); err != nil { + t.Fatalf("AddRoleToCatalog: %v", err) + } + + roles, err := stores.Teams.ListRoleCatalog(ctx, teamID) + if err != nil { + t.Fatalf("ListRoleCatalog: %v", err) + } + if len(roles) != 2 { + t.Fatalf("expected 2 roles, got %d: %v", len(roles), roles) + } +} + +func TestRoleCatalog_AddIdempotent(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, _, _ := seedTeamAndMember(t, stores) + pkgA := "rc-idem-a-" + store.NewID()[:6] + pkgB := "rc-idem-b-" + store.NewID()[:6] + seedMinimalPackage(t, stores, pkgA) + seedMinimalPackage(t, stores, pkgB) + + if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgA); err != nil { + t.Fatalf("first add: %v", err) + } + // Same role from different source — should be idempotent (unique on team_id, role) + if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgB); err != nil { + t.Fatalf("idempotent add should not error: %v", err) + } + + roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID) + if len(roles) != 1 { + t.Errorf("expected 1 role after idempotent add, got %d", len(roles)) + } +} + +func TestRoleCatalog_RemoveBySource(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, _, _ := seedTeamAndMember(t, stores) + pkgRm := "rc-rm-" + store.NewID()[:6] + pkgKeep := "rc-keep-" + store.NewID()[:6] + seedMinimalPackage(t, stores, pkgRm) + seedMinimalPackage(t, stores, pkgKeep) + + stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgRm) + stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgRm) + stores.Teams.AddRoleToCatalog(ctx, teamID, "admin-role", pkgKeep) + + if err := stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgRm); err != nil { + t.Fatalf("RemoveRoleCatalogBySource: %v", err) + } + + roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID) + if len(roles) != 1 { + t.Fatalf("expected 1 role remaining, got %d: %v", len(roles), roles) + } + if roles[0].Role != "admin-role" { + t.Errorf("expected 'admin-role' remaining, got %q", roles[0].Role) + } +} + +// ── Store: package adoption ────────────── + +func seedAdoptablePackage(t *testing.T, stores store.Stores, id string, roles []string) { + t.Helper() + manifest := map[string]any{ + "id": id, + "title": "Adoptable " + id, + "type": "surface", + } + if len(roles) > 0 { + rr := make([]any, len(roles)) + for i, r := range roles { + rr[i] = r + } + manifest["requires_roles"] = rr + } + pkg := &store.PackageRegistration{ + ID: id, + Title: "Adoptable " + id, + Type: "surface", + Version: "1.0.0", + Tier: "browser", + Scope: "global", + Manifest: manifest, + Enabled: true, + Status: "active", + Source: "extension", + Adoptable: true, + } + if err := stores.Packages.Create(context.Background(), pkg); err != nil { + t.Fatalf("seed adoptable package: %v", err) + } +} + +func TestAdoptPackage_Success(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + ctx := context.Background() + + teamID, userID, _ := seedTeamAndMember(t, stores) + pkgID := "adopt-test-" + store.NewID()[:6] + seedAdoptablePackage(t, stores, pkgID, []string{"approver", "reviewer"}) + + // Set up handler + h := NewPackageAdoptHandler(stores) + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) { + c.Set("user_id", userID) + h.AdoptPackage(c) + }) + + req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil) + c.Request = req + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + + // Verify roles_added + rolesAdded, ok := resp["roles_added"].([]any) + if !ok || len(rolesAdded) != 2 { + t.Errorf("expected 2 roles_added, got %v", resp["roles_added"]) + } + + // Verify team package was created + shortTeam := teamID + if len(shortTeam) > 8 { + shortTeam = shortTeam[:8] + } + adopted, _ := stores.Packages.Get(ctx, pkgID+"--"+shortTeam) + if adopted == nil { + t.Fatal("adopted package not found in DB") + } + if adopted.Scope != "team" { + t.Errorf("expected scope 'team', got %q", adopted.Scope) + } + if adopted.AdoptedFrom == nil || *adopted.AdoptedFrom != pkgID { + t.Errorf("expected adopted_from=%q, got %v", pkgID, adopted.AdoptedFrom) + } + + // Verify role catalog populated + roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID) + found := 0 + for _, r := range roles { + if r.SourcePackageID == pkgID+"--"+shortTeam { + found++ + } + } + if found != 2 { + t.Errorf("expected 2 catalog roles from adoption, got %d", found) + } +} + +func TestAdoptPackage_Idempotent(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + + teamID, userID, _ := seedTeamAndMember(t, stores) + pkgID := "adopt-idem-" + store.NewID()[:6] + seedAdoptablePackage(t, stores, pkgID, nil) + + h := NewPackageAdoptHandler(stores) + gin.SetMode(gin.TestMode) + + doAdopt := func() int { + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) { + c.Set("user_id", userID) + h.AdoptPackage(c) + }) + req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil) + r.ServeHTTP(w, req) + return w.Code + } + + code1 := doAdopt() + if code1 != http.StatusCreated { + t.Fatalf("first adopt: expected 201, got %d", code1) + } + code2 := doAdopt() + if code2 != http.StatusOK { + t.Fatalf("second adopt: expected 200, got %d", code2) + } +} + +func TestAdoptPackage_NotAdoptable(t *testing.T) { + database.RequireTestDB(t) + stores := testStores(t) + + teamID, userID, _ := seedTeamAndMember(t, stores) + + // Seed a non-adoptable package + pkg := &store.PackageRegistration{ + ID: "no-adopt-" + store.NewID()[:6], Title: "Not Adoptable", + Type: "surface", Version: "1.0.0", Tier: "browser", Scope: "global", + Manifest: map[string]any{"id": "no-adopt", "title": "Not Adoptable"}, + Enabled: true, Status: "active", Source: "extension", + Adoptable: false, + } + if err := stores.Packages.Create(context.Background(), pkg); err != nil { + t.Fatalf("seed non-adoptable: %v", err) + } + + h := NewPackageAdoptHandler(stores) + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) { + c.Set("user_id", userID) + h.AdoptPackage(c) + }) + req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkg.ID+"/adopt", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for non-adoptable, got %d", w.Code) + } +} diff --git a/server/handlers/package_validate.go b/server/handlers/package_validate.go index c730644..53b9772 100644 --- a/server/handlers/package_validate.go +++ b/server/handlers/package_validate.go @@ -34,6 +34,7 @@ type ManifestInfo struct { UserPermissions []string // user-facing permissions declared by the extension GatePermission string // if set, checks this user permission before calling on_request RequiresRoles []string // team roles needed to access this package (advisory, OR semantics) + Adoptable bool // if true, teams can adopt this package to get a team-scoped copy } // ValidateManifest parses a manifest map and validates all required fields, @@ -178,6 +179,11 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { } } + // v0.9.4: adoptable — teams can adopt this package to get a team-scoped copy + if v, ok := manifest["adoptable"].(bool); ok { + info.Adoptable = v + } + info.SchemaVersion = ParseSchemaVersion(manifest) // ── Type-specific constraints ──────────────────────────────── @@ -216,10 +222,16 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { if info.HasRoute { return nil, fmt.Errorf("library packages cannot have a route") } + if info.Adoptable { + return nil, fmt.Errorf("library packages cannot be adoptable") + } case "test-runner": // Test runners are surface-like packages discovered by type. // They are not shown in navigation (extensionNavItems filters for surface/full). // They may have a route but it's optional — the registry surface provides access. + if info.Adoptable { + return nil, fmt.Errorf("test-runner packages cannot be adoptable") + } } return info, nil diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 8199296..5f26bd4 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -591,6 +591,18 @@ func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *Ma if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil { log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err) } + + // v0.9.4: persist adoptable flag (Update doesn't cover it — separate column) + if mInfo.Adoptable { + q := `UPDATE packages SET adoptable = true WHERE id = $1` + if !database.IsPostgres() { + q = `UPDATE packages SET adoptable = 1 WHERE id = ?` + } + if _, err := database.DB.ExecContext(c.Request.Context(), q, pkgID); err != nil { + log.Printf("[packages] Failed to set adoptable for %s: %v", pkgID, err) + } + } + return preservedEnabled, nil } diff --git a/server/handlers/workflow_team.go b/server/handlers/workflow_team.go index cf30316..912570b 100644 --- a/server/handlers/workflow_team.go +++ b/server/handlers/workflow_team.go @@ -45,7 +45,13 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool { // AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team. // The global original is left untouched so other teams can also adopt it. // POST /api/v1/teams/:teamId/workflows/:id/adopt +// +// Deprecated: Use POST /api/v1/teams/:teamId/packages/:id/adopt instead. +// This endpoint will be removed in a future version. func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) { + c.Header("X-Deprecated", "Use POST /api/v1/teams/:teamId/packages/:id/adopt instead") + log.Printf("[workflows] DEPRECATED: AdoptTeamWorkflow called for team %s — use POST /teams/:teamId/packages/:id/adopt", c.Param("teamId")) + ctx := c.Request.Context() teamID := c.Param("teamId") srcID := c.Param("id") diff --git a/server/main.go b/server/main.go index c730a89..2f8c1a4 100644 --- a/server/main.go +++ b/server/main.go @@ -660,6 +660,13 @@ func main() { teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage) teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage) + // Team package adoption + adoptH := handlers.NewPackageAdoptHandler(stores) + teamScoped.POST("/packages/:id/adopt", adoptH.AdoptPackage) + teamScoped.GET("/packages/adoptable", adoptH.ListAdoptablePackages) + teamScoped.DELETE("/packages/:id/unadopt", adoptH.UnadoptPackage) + teamScoped.GET("/roles/catalog", adoptH.ListRoleCatalog) + // Team package settings — cascade overrides teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores) teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings) diff --git a/server/pages/pages_handler_test.go b/server/pages/pages_handler_test.go index 969af19..10720dc 100644 --- a/server/pages/pages_handler_test.go +++ b/server/pages/pages_handler_test.go @@ -75,6 +75,8 @@ func (m *mockPackageStore) SetPackageSettings(context.Context, string, json.RawM func (m *mockPackageStore) GetTeamSettings(context.Context, string, string) (json.RawMessage, error) { return nil, nil } func (m *mockPackageStore) SetTeamSettings(context.Context, string, string, json.RawMessage) error { return nil } func (m *mockPackageStore) DeleteTeamSettings(context.Context, string, string) error { return nil } +func (m *mockPackageStore) ListAdoptable(context.Context) ([]store.PackageRegistration, error) { return nil, nil } +func (m *mockPackageStore) GetByAdoptedFrom(context.Context, string, string) (*store.PackageRegistration, error) { return nil, nil } // ── test helpers ───────────────────────────────────────────── diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 62a4ac9..675f578 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -208,6 +208,23 @@ type TeamStore interface { // RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal). RemoveAllUserRoles(ctx context.Context, teamID, userID string) error + + // ── v0.9.4 — Team Role Catalog ── + + // AddRoleToCatalog registers a known role for the team. Idempotent. + AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error + + // ListRoleCatalog returns all known roles for a team. + ListRoleCatalog(ctx context.Context, teamID string) ([]TeamRoleCatalogEntry, error) + + // RemoveRoleCatalogBySource removes all catalog entries sourced from a package. + RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error +} + +// TeamRoleCatalogEntry represents a known role sourced from an adopted package. +type TeamRoleCatalogEntry struct { + Role string `json:"role"` + SourcePackageID string `json:"source_package_id,omitempty"` } // ========================================= diff --git a/server/store/package_iface.go b/server/store/package_iface.go index d411925..f9d6693 100644 --- a/server/store/package_iface.go +++ b/server/store/package_iface.go @@ -93,6 +93,14 @@ type PackageStore interface { // DeleteTeamSettings removes team-scoped overrides, reverting to global defaults. DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error + + // ── v0.9.4 — Package Adoption ─────────────── + + // ListAdoptable returns global, adoptable, enabled packages. + ListAdoptable(ctx context.Context) ([]PackageRegistration, error) + + // GetByAdoptedFrom returns a team's adopted copy of a source package, or nil. + GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*PackageRegistration, error) } // PackageRegistration is a row from the packages table. @@ -114,6 +122,8 @@ type PackageRegistration struct { SchemaVersion int `json:"schema_version" db:"schema_version"` PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"` Source string `json:"source" db:"source"` + Adoptable bool `json:"adoptable" db:"adoptable"` + AdoptedFrom *string `json:"adopted_from,omitempty" db:"adopted_from"` InstalledAt string `json:"installed_at" db:"installed_at"` UpdatedAt string `json:"updated_at" db:"updated_at"` } diff --git a/server/store/postgres/packages.go b/server/store/postgres/packages.go index 58137fd..cb1bb8a 100644 --- a/server/store/postgres/packages.go +++ b/server/store/postgres/packages.go @@ -107,15 +107,15 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio return DB.QueryRowContext(ctx, ` INSERT INTO packages (id, title, type, version, description, author, tier, is_system, scope, team_id, installed_by, manifest, enabled, status, - schema_version, package_settings, source) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + schema_version, package_settings, source, adoptable, adopted_from) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) RETURNING installed_at, updated_at`, pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author, pkg.Tier, pkg.IsSystem, pkg.Scope, nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy), manifestJSON, pkg.Enabled, pkg.Status, pkg.SchemaVersion, defaultJSON(pkg.PackageSettings), - pkg.Source, + pkg.Source, pkg.Adoptable, nullStrPtr(pkg.AdoptedFrom), ).Scan(&pkg.InstalledAt, &pkg.UpdatedAt) } @@ -173,7 +173,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. var result []store.UserPackage for rows.Next() { var up store.UserPackage - var teamID, installedBy sql.NullString + var teamID, installedBy, adoptedFrom sql.NullString var manifestJSON []byte var pkgSettings []byte var userEnabled sql.NullBool @@ -185,13 +185,15 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. &teamID, &installedBy, &manifestJSON, &up.Enabled, &up.Status, &up.SchemaVersion, &pkgSettings, - &up.Source, &up.InstalledAt, &up.UpdatedAt, + &up.Source, &up.Adoptable, &adoptedFrom, + &up.InstalledAt, &up.UpdatedAt, &userEnabled, &userSettings, ); err != nil { return nil, err } up.TeamID = NullableStringPtr(teamID) up.InstalledBy = NullableStringPtr(installedBy) + up.AdoptedFrom = NullableStringPtr(adoptedFrom) json.Unmarshal(manifestJSON, &up.Manifest) up.PackageSettings = json.RawMessage(pkgSettings) if userEnabled.Valid { @@ -250,11 +252,12 @@ const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author, p.tier, p.is_system, p.scope, p.team_id, p.installed_by, p.manifest, p.enabled, p.status, p.schema_version, p.package_settings, - p.source, p.installed_at, p.updated_at` + p.source, p.adoptable, p.adopted_from, + p.installed_at, p.updated_at` func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) { var pkg store.PackageRegistration - var teamID, installedBy sql.NullString + var teamID, installedBy, adoptedFrom sql.NullString var manifestJSON []byte var pkgSettings []byte err := DB.QueryRowContext(ctx, query, args...).Scan( @@ -263,7 +266,8 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf &teamID, &installedBy, &manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.SchemaVersion, &pkgSettings, - &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, + &pkg.Source, &pkg.Adoptable, &adoptedFrom, + &pkg.InstalledAt, &pkg.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil @@ -273,6 +277,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf } pkg.TeamID = NullableStringPtr(teamID) pkg.InstalledBy = NullableStringPtr(installedBy) + pkg.AdoptedFrom = NullableStringPtr(adoptedFrom) json.Unmarshal(manifestJSON, &pkg.Manifest) pkg.PackageSettings = json.RawMessage(pkgSettings) return &pkg, nil @@ -288,7 +293,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter var result []store.PackageRegistration for rows.Next() { var pkg store.PackageRegistration - var teamID, installedBy sql.NullString + var teamID, installedBy, adoptedFrom sql.NullString var manifestJSON []byte var pkgSettings []byte if err := rows.Scan( @@ -297,12 +302,14 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter &teamID, &installedBy, &manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.SchemaVersion, &pkgSettings, - &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, + &pkg.Source, &pkg.Adoptable, &adoptedFrom, + &pkg.InstalledAt, &pkg.UpdatedAt, ); err != nil { return nil, err } pkg.TeamID = NullableStringPtr(teamID) pkg.InstalledBy = NullableStringPtr(installedBy) + pkg.AdoptedFrom = NullableStringPtr(adoptedFrom) json.Unmarshal(manifestJSON, &pkg.Manifest) pkg.PackageSettings = json.RawMessage(pkgSettings) result = append(result, pkg) @@ -397,6 +404,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str return err } +// ── v0.9.4 — Package Adoption ─────────────── + +func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) { + return s.scanMany(ctx, ` + SELECT `+pkgCols+` + FROM packages p + WHERE p.adoptable = true AND p.scope = 'global' AND p.enabled = true + ORDER BY p.title`) +} + +func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) { + return s.scanOne(ctx, ` + SELECT `+pkgCols+` + FROM packages p + WHERE p.adopted_from = $1 AND p.team_id = $2`, sourceID, teamID) +} + // nullStrPtr converts *string to sql.NullString for nullable FK columns. func nullStrPtr(s *string) sql.NullString { if s == nil { diff --git a/server/store/postgres/team.go b/server/store/postgres/team.go index eb3f36a..67ae8ac 100644 --- a/server/store/postgres/team.go +++ b/server/store/postgres/team.go @@ -6,6 +6,7 @@ import ( "encoding/json" "armature/models" + "armature/store" ) type TeamStore struct{} @@ -393,3 +394,40 @@ func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID strin teamID, userID) return err } + +// ── v0.9.4 — Team Role Catalog ── + +func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO team_role_catalog (id, team_id, role, source_package_id) + VALUES (gen_random_uuid(), $1, $2, $3) + ON CONFLICT (team_id, role) DO NOTHING`, + teamID, role, sourcePkgID) + return err +} + +func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog + WHERE team_id = $1 ORDER BY role`, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []store.TeamRoleCatalogEntry + for rows.Next() { + var e store.TeamRoleCatalogEntry + if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil { + return nil, err + } + result = append(result, e) + } + return result, rows.Err() +} + +func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error { + _, err := DB.ExecContext(ctx, ` + DELETE FROM team_role_catalog WHERE team_id = $1 AND source_package_id = $2`, + teamID, sourcePkgID) + return err +} diff --git a/server/store/sqlite/packages.go b/server/store/sqlite/packages.go index 6d8934f..b6d85a1 100644 --- a/server/store/sqlite/packages.go +++ b/server/store/sqlite/packages.go @@ -109,18 +109,22 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio if pkg.Status == "" { pkg.Status = "active" } + adoptableInt := 0 + if pkg.Adoptable { + adoptableInt = 1 + } _, err := DB.ExecContext(ctx, ` INSERT INTO packages (id, title, type, version, description, author, tier, is_system, scope, team_id, installed_by, manifest, enabled, status, - schema_version, package_settings, source, + schema_version, package_settings, source, adoptable, adopted_from, installed_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author, pkg.Tier, pkg.IsSystem, pkg.Scope, nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy), manifestJSON, pkg.Enabled, pkg.Status, pkg.SchemaVersion, defaultJSON(pkg.PackageSettings), - pkg.Source, + pkg.Source, adoptableInt, nullStrPtr(pkg.AdoptedFrom), now.Format(timeFmt), now.Format(timeFmt), ) if err != nil { @@ -184,11 +188,12 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. var result []store.UserPackage for rows.Next() { var up store.UserPackage - var teamID, installedBy sql.NullString + var teamID, installedBy, adoptedFrom sql.NullString var manifestJSON string var pkgSettings string var enabledInt int var isSystemInt int + var adoptableInt int var userEnabled sql.NullBool var userSettings []byte @@ -198,15 +203,18 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. &teamID, &installedBy, &manifestJSON, &enabledInt, &up.Status, &up.SchemaVersion, &pkgSettings, - &up.Source, &up.InstalledAt, &up.UpdatedAt, + &up.Source, &adoptableInt, &adoptedFrom, + &up.InstalledAt, &up.UpdatedAt, &userEnabled, &userSettings, ); err != nil { return nil, err } up.IsSystem = isSystemInt != 0 up.Enabled = enabledInt != 0 + up.Adoptable = adoptableInt != 0 up.TeamID = NullableStringPtr(teamID) up.InstalledBy = NullableStringPtr(installedBy) + up.AdoptedFrom = NullableStringPtr(adoptedFrom) json.Unmarshal([]byte(manifestJSON), &up.Manifest) up.PackageSettings = json.RawMessage(pkgSettings) if userEnabled.Valid { @@ -263,22 +271,25 @@ const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author, p.tier, p.is_system, p.scope, p.team_id, p.installed_by, p.manifest, p.enabled, p.status, p.schema_version, p.package_settings, - p.source, p.installed_at, p.updated_at` + p.source, p.adoptable, p.adopted_from, + p.installed_at, p.updated_at` func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) { var pkg store.PackageRegistration - var teamID, installedBy sql.NullString + var teamID, installedBy, adoptedFrom sql.NullString var manifestJSON string var pkgSettings string var enabledInt int var isSystemInt int + var adoptableInt int err := DB.QueryRowContext(ctx, query, args...).Scan( &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope, &teamID, &installedBy, &manifestJSON, &enabledInt, &pkg.Status, &pkg.SchemaVersion, &pkgSettings, - &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, + &pkg.Source, &adoptableInt, &adoptedFrom, + &pkg.InstalledAt, &pkg.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil @@ -288,8 +299,10 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf } pkg.IsSystem = isSystemInt != 0 pkg.Enabled = enabledInt != 0 + pkg.Adoptable = adoptableInt != 0 pkg.TeamID = NullableStringPtr(teamID) pkg.InstalledBy = NullableStringPtr(installedBy) + pkg.AdoptedFrom = NullableStringPtr(adoptedFrom) json.Unmarshal([]byte(manifestJSON), &pkg.Manifest) pkg.PackageSettings = json.RawMessage(pkgSettings) return &pkg, nil @@ -305,25 +318,29 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter var result []store.PackageRegistration for rows.Next() { var pkg store.PackageRegistration - var teamID, installedBy sql.NullString + var teamID, installedBy, adoptedFrom sql.NullString var manifestJSON string var pkgSettings string var enabledInt int var isSystemInt int + var adoptableInt int if err := rows.Scan( &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope, &teamID, &installedBy, &manifestJSON, &enabledInt, &pkg.Status, &pkg.SchemaVersion, &pkgSettings, - &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, + &pkg.Source, &adoptableInt, &adoptedFrom, + &pkg.InstalledAt, &pkg.UpdatedAt, ); err != nil { return nil, err } pkg.IsSystem = isSystemInt != 0 pkg.Enabled = enabledInt != 0 + pkg.Adoptable = adoptableInt != 0 pkg.TeamID = NullableStringPtr(teamID) pkg.InstalledBy = NullableStringPtr(installedBy) + pkg.AdoptedFrom = NullableStringPtr(adoptedFrom) json.Unmarshal([]byte(manifestJSON), &pkg.Manifest) pkg.PackageSettings = json.RawMessage(pkgSettings) result = append(result, pkg) @@ -418,6 +435,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str return err } +// ── v0.9.4 — Package Adoption ─────────────── + +func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) { + return s.scanMany(ctx, ` + SELECT `+pkgCols+` + FROM packages p + WHERE p.adoptable = 1 AND p.scope = 'global' AND p.enabled = 1 + ORDER BY p.title`) +} + +func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) { + return s.scanOne(ctx, ` + SELECT `+pkgCols+` + FROM packages p + WHERE p.adopted_from = ? AND p.team_id = ?`, sourceID, teamID) +} + func nullStrPtr(s *string) sql.NullString { if s == nil { return sql.NullString{} diff --git a/server/store/sqlite/team.go b/server/store/sqlite/team.go index 18c5d74..3a807e2 100644 --- a/server/store/sqlite/team.go +++ b/server/store/sqlite/team.go @@ -8,6 +8,8 @@ import ( "armature/models" "armature/store" + + "github.com/google/uuid" ) type TeamStore struct{} @@ -399,3 +401,40 @@ func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID strin teamID, userID) return err } + +// ── v0.9.4 — Team Role Catalog ── + +func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO team_role_catalog (id, team_id, role, source_package_id) + VALUES (?, ?, ?, ?) + ON CONFLICT (team_id, role) DO NOTHING`, + uuid.New().String(), teamID, role, sourcePkgID) + return err +} + +func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog + WHERE team_id = ? ORDER BY role`, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []store.TeamRoleCatalogEntry + for rows.Next() { + var e store.TeamRoleCatalogEntry + if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil { + return nil, err + } + result = append(result, e) + } + return result, rows.Err() +} + +func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error { + _, err := DB.ExecContext(ctx, ` + DELETE FROM team_role_catalog WHERE team_id = ? AND source_package_id = ?`, + teamID, sourcePkgID) + return err +}