All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 3m6s
CI/CD / build-and-deploy (push) Successful in 29s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
279 lines
9.3 KiB
Go
279 lines
9.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"armature/forms"
|
|
)
|
|
|
|
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
|
|
// Duplicated from packages.go for use in standalone validation.
|
|
var validManifestID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
|
|
|
|
// ManifestInfo holds the parsed and validated fields from a manifest.json.
|
|
type ManifestInfo struct {
|
|
ID string
|
|
Title string
|
|
Type string // surface, extension, full, workflow, library, test-runner
|
|
Version string
|
|
Description string
|
|
Author string
|
|
Tier string
|
|
SchemaVersion int
|
|
HasRoute bool
|
|
HasSurfaces bool
|
|
HasTools bool
|
|
HasPipes bool
|
|
HasHooks bool
|
|
HasSettings bool
|
|
HasExports bool
|
|
Dependencies map[string]any
|
|
Requires []string
|
|
Signature string // reserved for future package signing
|
|
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
|
|
HasFormTemplate bool // if true, package declares a form_template at the top level
|
|
}
|
|
|
|
// ValidateManifest parses a manifest map and validates all required fields,
|
|
// type constraints, and structural rules. Returns a ManifestInfo or an error
|
|
// describing the first validation failure.
|
|
func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
|
if manifest == nil {
|
|
return nil, fmt.Errorf("manifest is nil")
|
|
}
|
|
|
|
info := &ManifestInfo{}
|
|
|
|
// ── Required fields ──────────────────────────────────────────
|
|
info.ID, _ = manifest["id"].(string)
|
|
info.Title, _ = manifest["title"].(string)
|
|
if info.ID == "" || info.Title == "" {
|
|
return nil, fmt.Errorf("manifest must have 'id' and 'title'")
|
|
}
|
|
|
|
// ── Package ID slug validation ───────────────────────────────
|
|
if !validManifestID.MatchString(info.ID) {
|
|
return nil, fmt.Errorf("package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)")
|
|
}
|
|
|
|
// ── Type validation ──────────────────────────────────────────
|
|
info.Type, _ = manifest["type"].(string)
|
|
if info.Type == "" {
|
|
info.Type = "surface" // backward compat
|
|
}
|
|
validTypes := map[string]bool{
|
|
"surface": true, "extension": true, "full": true,
|
|
"workflow": true, "library": true, "test-runner": true,
|
|
}
|
|
if !validTypes[info.Type] {
|
|
return nil, fmt.Errorf("manifest type must be 'surface', 'extension', 'full', 'workflow', 'library', or 'test-runner'")
|
|
}
|
|
|
|
// ── Extract optional fields ──────────────────────────────────
|
|
info.Version, _ = manifest["version"].(string)
|
|
if info.Version == "" {
|
|
info.Version = "0.0.0"
|
|
}
|
|
info.Description, _ = manifest["description"].(string)
|
|
info.Author, _ = manifest["author"].(string)
|
|
info.Tier, _ = manifest["tier"].(string)
|
|
if info.Tier == "" {
|
|
info.Tier = "browser"
|
|
}
|
|
info.Signature, _ = manifest["signature"].(string)
|
|
|
|
// ── Surfaces validation ─────────────────────────────────────
|
|
if rawSurfaces, ok := manifest["surfaces"].([]any); ok {
|
|
if len(rawSurfaces) == 0 {
|
|
return nil, fmt.Errorf("surfaces array cannot be empty")
|
|
}
|
|
seen := map[string]bool{}
|
|
for i, raw := range rawSurfaces {
|
|
s, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("surfaces[%d] must be an object", i)
|
|
}
|
|
path, _ := s["path"].(string)
|
|
if path == "" {
|
|
return nil, fmt.Errorf("surfaces[%d] requires a path", i)
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
return nil, fmt.Errorf("surfaces[%d] path must start with /", i)
|
|
}
|
|
if seen[path] {
|
|
return nil, fmt.Errorf("duplicate surface path: %s", path)
|
|
}
|
|
seen[path] = true
|
|
if access, ok := s["access"].(string); ok {
|
|
if !validAccessLevels(access) {
|
|
return nil, fmt.Errorf("surfaces[%d] invalid access: %s", i, access)
|
|
}
|
|
}
|
|
}
|
|
info.HasSurfaces = true
|
|
info.HasRoute = true
|
|
} else if manifest["surfaces"] != nil {
|
|
// surfaces key present but not an array
|
|
return nil, fmt.Errorf("surfaces must be an array")
|
|
} else {
|
|
// No surfaces declared — synthesize from legacy fields.
|
|
// This ensures every routable package always has a surfaces array.
|
|
if info.Type == "surface" || info.Type == "full" {
|
|
auth, _ := manifest["auth"].(string)
|
|
if auth == "" {
|
|
auth = "authenticated"
|
|
}
|
|
layout, _ := manifest["layout"].(string)
|
|
if layout == "" {
|
|
layout = "single"
|
|
}
|
|
manifest["surfaces"] = []any{
|
|
map[string]any{
|
|
"path": "/",
|
|
"access": auth,
|
|
"layout": layout,
|
|
"title": info.Title,
|
|
},
|
|
}
|
|
info.HasSurfaces = true
|
|
}
|
|
info.HasRoute = manifest["route"] != nil || manifest["routes"] != nil || info.HasSurfaces
|
|
}
|
|
|
|
info.HasTools = manifest["tools"] != nil
|
|
info.HasPipes = manifest["pipes"] != nil
|
|
info.HasHooks = manifest["hooks"] != nil
|
|
info.HasSettings = manifest["settings"] != nil
|
|
info.HasExports = false
|
|
|
|
if deps, ok := manifest["dependencies"].(map[string]any); ok {
|
|
info.Dependencies = deps
|
|
}
|
|
if reqs, ok := manifest["requires"].([]any); ok {
|
|
for _, r := range reqs {
|
|
if s, ok := r.(string); ok && s != "" {
|
|
info.Requires = append(info.Requires, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extension-declared user permissions
|
|
if ups, ok := manifest["user_permissions"].([]any); ok {
|
|
for _, v := range ups {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
info.UserPermissions = append(info.UserPermissions, s)
|
|
}
|
|
}
|
|
}
|
|
info.GatePermission, _ = manifest["gate_permission"].(string)
|
|
|
|
// v0.9.3: requires_roles — team roles needed to access this package (advisory)
|
|
if rr, ok := manifest["requires_roles"].([]any); ok {
|
|
for _, v := range rr {
|
|
if s, ok := v.(string); ok && s != "" && len(s) <= 50 {
|
|
info.RequiresRoles = append(info.RequiresRoles, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// v0.9.5: form_template — any package can declare a typed form template
|
|
if manifest["form_template"] != nil {
|
|
raw, err := json.Marshal(manifest["form_template"])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("form_template is invalid JSON")
|
|
}
|
|
if tpl := forms.ParseTypedFormTemplate(raw); tpl == nil {
|
|
return nil, fmt.Errorf("form_template is invalid or empty")
|
|
}
|
|
info.HasFormTemplate = true
|
|
}
|
|
|
|
info.SchemaVersion = ParseSchemaVersion(manifest)
|
|
|
|
// ── Type-specific constraints ────────────────────────────────
|
|
hasExtBehavior := info.HasTools || info.HasPipes || info.HasHooks
|
|
|
|
switch info.Type {
|
|
case "surface":
|
|
// route is optional for surfaces
|
|
case "extension":
|
|
if !hasExtBehavior {
|
|
return nil, fmt.Errorf("extension packages require at least one of: tools, pipes, hooks")
|
|
}
|
|
if info.HasRoute {
|
|
return nil, fmt.Errorf("extension packages cannot have a route — use type 'full' for both")
|
|
}
|
|
case "full":
|
|
if !info.HasRoute {
|
|
return nil, fmt.Errorf("full packages require a route")
|
|
}
|
|
if !hasExtBehavior && !info.HasSettings {
|
|
return nil, fmt.Errorf("full packages require at least one of: tools, pipes, hooks, settings")
|
|
}
|
|
case "workflow":
|
|
if manifest["workflow_definition"] == nil {
|
|
return nil, fmt.Errorf("workflow packages require a 'workflow_definition' in the manifest")
|
|
}
|
|
case "library":
|
|
exports, _ := manifest["exports"].([]any)
|
|
if len(exports) == 0 {
|
|
return nil, fmt.Errorf("library packages require an 'exports' array")
|
|
}
|
|
info.HasExports = true
|
|
if info.HasTools || info.HasPipes {
|
|
return nil, fmt.Errorf("library packages cannot have tools or pipes")
|
|
}
|
|
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
|
|
}
|
|
|
|
// validAccessLevels checks whether an access string is a recognized value.
|
|
func validAccessLevels(access string) bool {
|
|
switch {
|
|
case access == "public", access == "authenticated", access == "admin":
|
|
return true
|
|
case strings.HasPrefix(access, "group:"):
|
|
return strings.TrimPrefix(access, "group:") != ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ValidateManifestJSON parses raw JSON bytes into a manifest map and validates.
|
|
func ValidateManifestJSON(data []byte) (map[string]any, *ManifestInfo, error) {
|
|
var manifest map[string]any
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return nil, nil, fmt.Errorf("invalid manifest.json: %w", err)
|
|
}
|
|
info, err := ValidateManifest(manifest)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return manifest, info, nil
|
|
}
|