Feat v0.9.4 package adoption roles #78

Merged
xcaliber merged 2 commits from feat/v0.9.4-package-adoption-roles into main 2026-04-03 16:23:43 +00:00
17 changed files with 959 additions and 24 deletions
Showing only changes of commit 166acce2e9 - Show all commits

View File

@@ -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

View File

@@ -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**

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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)

View File

@@ -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 ─────────────────────────────────────────────

View File

@@ -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"`
}
// =========================================

View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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{}

View File

@@ -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
}