// Package manifest validates the standalone Business Plugin V1 manifest. package manifest import ( "bytes" "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/url" "os" "regexp" "sort" "strings" ) var ( pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`) versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) methodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$`) pathParam = regexp.MustCompile(`^\{[a-zA-Z][a-zA-Z0-9_-]*\}$`) sha256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`) ) // RequiredAllowlist contains the Core endpoints needed by every plugin BFF. // Domain-specific read/write endpoints must be appended by each plugin. var requiredAllowlist = []string{ "POST /api/v1/auth/login", "POST /api/v1/auth/login/2fa", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", "GET /api/v1/auth/me", "GET /api/v1/settings/public", } type Manifest struct { SchemaVersion int `json:"schema_version"` PluginID string `json:"plugin_id"` Name string `json:"name"` Version string `json:"version"` CoreAPIBaseline string `json:"core_api_baseline"` Capabilities []string `json:"capabilities"` TestedCoreVersions []string `json:"tested_core_versions"` Backend Backend `json:"backend"` UI UI `json:"ui"` Publisher Publisher `json:"publisher"` CoreAPIAllowlist []string `json:"core_api_allowlist"` Files map[string]string `json:"files,omitempty"` } type Backend struct { HealthPath string `json:"health_path"` ReadinessPath string `json:"readiness_path"` ListenEnv string `json:"listen_env"` Command string `json:"command,omitempty"` } type UI struct { Entrypoint string `json:"entrypoint"` Menu Menu `json:"menu"` } type Menu struct { ID string `json:"id"` Label string `json:"label"` Visibility string `json:"visibility"` SortOrder int `json:"sort_order"` URL string `json:"url,omitempty"` } type Publisher struct { KeyID string `json:"key_id"` } type Signature struct { Algorithm string `json:"algorithm"` KeyID string `json:"key_id"` Signature string `json:"signature"` } // Compatibility is the control-plane decision for the current Core version. type Compatibility struct { Compatible bool `json:"compatible"` Tested bool `json:"tested"` Status string `json:"status"` Message string `json:"message"` } func Load(path string) (Manifest, []byte, error) { raw, err := os.ReadFile(path) if err != nil { return Manifest{}, nil, err } var m Manifest dec := json.NewDecoder(strings.NewReader(string(raw))) dec.DisallowUnknownFields() if err := rejectDuplicateJSONKeys(raw); err != nil { return Manifest{}, nil, err } if err := dec.Decode(&m); err != nil { return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err) } var trailing any if err := dec.Decode(&trailing); err != io.EOF { if err == nil { return Manifest{}, nil, errors.New("manifest contains trailing JSON") } return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err) } if err := Validate(m); err != nil { return Manifest{}, nil, err } return m, raw, nil } func Validate(m Manifest) error { if m.SchemaVersion != 1 { return errors.New("schema_version must be 1") } if !pluginIDPattern.MatchString(m.PluginID) || len(m.PluginID) > 160 { return errors.New("invalid plugin_id") } if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 { return errors.New("name is required and must be at most 160 characters") } if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) { return errors.New("invalid plugin version") } baseline := strings.TrimPrefix(strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-"), "v") if !versionPattern.MatchString(baseline) { return errors.New("invalid core_api_baseline") } if len(m.Capabilities) == 0 { return errors.New("capabilities must contain at least one capability") } seenCaps := map[string]struct{}{} for _, capability := range m.Capabilities { if !pluginIDPattern.MatchString(capability) || len(capability) > 160 { return fmt.Errorf("invalid capability: %s", capability) } if _, ok := seenCaps[capability]; ok { return fmt.Errorf("duplicate capability: %s", capability) } seenCaps[capability] = struct{}{} } for _, version := range m.TestedCoreVersions { if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) { return fmt.Errorf("invalid tested_core_versions entry: %s", version) } } if err := validateEndpointPath(m.Backend.HealthPath); err != nil { return fmt.Errorf("backend.health_path: %w", err) } if err := validateEndpointPath(m.Backend.ReadinessPath); err != nil { return fmt.Errorf("backend.readiness_path: %w", err) } if m.Backend.Command != "" { if err := validateRelativePath(m.Backend.Command); err != nil || !strings.HasPrefix(strings.ReplaceAll(m.Backend.Command, "\\", "/"), "service/") { return errors.New("backend.command must be a safe path under service/") } } if strings.TrimSpace(m.Backend.ListenEnv) == "" || !regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`).MatchString(m.Backend.ListenEnv) { return errors.New("backend.listen_env is invalid") } if err := validateUIEntrypoint(m.UI.Entrypoint); err != nil { return fmt.Errorf("ui.entrypoint: %w", err) } if m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || len(m.UI.Menu.Label) > 160 || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 { return errors.New("ui.menu must bind to plugin_id, use admin visibility, and have a valid label/order") } if m.UI.Menu.URL != "" { u, err := url.Parse(strings.TrimSpace(m.UI.Menu.URL)) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { return errors.New("ui.menu.url must be an absolute http(s) URL without credentials or query") } } if strings.TrimSpace(m.Publisher.KeyID) == "" || len(m.Publisher.KeyID) > 160 { return errors.New("publisher.key_id is required") } if len(m.CoreAPIAllowlist) < len(requiredAllowlist) { return fmt.Errorf("core_api_allowlist must contain at least %d entries", len(requiredAllowlist)) } seen := map[string]struct{}{} for _, entry := range m.CoreAPIAllowlist { if err := validateAllowlistEntry(entry); err != nil { return err } if _, ok := seen[entry]; ok { return fmt.Errorf("duplicate allowlist entry: %s", entry) } seen[entry] = struct{}{} } for _, required := range requiredAllowlist { if _, ok := seen[required]; !ok { return fmt.Errorf("missing required allowlist entry: %s", required) } } for path, hash := range m.Files { if err := validateRelativePath(path); err != nil || !sha256Pattern.MatchString(hash) { return fmt.Errorf("invalid file hash declaration: %s", path) } } if len(m.Files) > 0 && strings.HasPrefix(m.UI.Entrypoint, "ui/") { if _, ok := m.Files[m.UI.Entrypoint]; !ok { return errors.New("ui.entrypoint is missing from files") } } return nil } func validatePluginPath(value string) error { p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") { return errors.New("must be a safe plugin path") } return nil } func validateEndpointPath(value string) error { p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.ContainsAny(p, "*?#") { return errors.New("must be a safe absolute endpoint path") } return nil } func validateRelativePath(value string) error { p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") // Reject rooted paths, traversal, URL/drive syntax, and glob/query fragments. if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") { return errors.New("must be a safe relative path") } return nil } func validateUIEntrypoint(value string) error { p := strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) if p == "/admin" || p == "/admin/" { return nil } return validateRelativePath(p) } func validateAllowlistEntry(entry string) error { parts := strings.Fields(entry) if len(parts) != 2 || !methodPattern.MatchString(parts[0]) { return fmt.Errorf("invalid core_api_allowlist entry: %s", entry) } p := parts[1] if !strings.HasPrefix(p, "/api/v1/") || strings.ContainsAny(p, "?#*") || strings.Contains(p, "//") || strings.Contains(p, "..") { return fmt.Errorf("core_api_allowlist entry must be an exact Core path: %s", entry) } for _, segment := range strings.Split(strings.TrimPrefix(p, "/"), "/") { if strings.Contains(segment, "{") || strings.Contains(segment, "}") { if !pathParam.MatchString(segment) { return fmt.Errorf("invalid path parameter in allowlist entry: %s", entry) } } } return nil } func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error { if err := rejectDuplicateJSONKeys(signatureBytes); err != nil { return err } var signature Signature dec := json.NewDecoder(strings.NewReader(string(signatureBytes))) dec.DisallowUnknownFields() if err := dec.Decode(&signature); err != nil { return fmt.Errorf("decode signature: %w", err) } var trailing any if err := dec.Decode(&trailing); err != io.EOF { return errors.New("signature contains trailing JSON") } if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" { return errors.New("signature must use ed25519 and include key_id") } publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes))) if err != nil || len(publicKey) != ed25519.PublicKeySize { return errors.New("invalid base64 ed25519 public key") } sig, err := base64.StdEncoding.DecodeString(signature.Signature) if err != nil || len(sig) != ed25519.SignatureSize { return errors.New("invalid base64 ed25519 signature") } if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) { return errors.New("manifest signature verification failed") } return nil } func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error { if err := rejectDuplicateJSONKeys(signatureBytes); err != nil { return err } var signature Signature dec := json.NewDecoder(strings.NewReader(string(signatureBytes))) dec.DisallowUnknownFields() if err := dec.Decode(&signature); err != nil { return fmt.Errorf("decode signature: %w", err) } var trailing any if err := dec.Decode(&trailing); err != io.EOF { return errors.New("signature contains trailing JSON") } if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID { return errors.New("signature key_id does not match manifest publisher") } return nil } // rejectDuplicateJSONKeys performs a token-level walk because encoding/json // otherwise accepts duplicate object members and silently keeps the last one. func rejectDuplicateJSONKeys(raw []byte) error { dec := json.NewDecoder(bytes.NewReader(raw)) var walk func() error walk = func() error { tok, err := dec.Token() if err != nil { return err } delim, ok := tok.(json.Delim) if !ok { return nil } switch delim { case '{': seen := map[string]struct{}{} for dec.More() { key, err := dec.Token() if err != nil { return err } name, ok := key.(string) if !ok { return errors.New("object member name must be a string") } if _, exists := seen[name]; exists { return fmt.Errorf("duplicate JSON object key: %s", name) } seen[name] = struct{}{} if err := walk(); err != nil { return err } } end, err := dec.Token() if err != nil { return err } if end != json.Delim('}') { return errors.New("invalid JSON object") } case '[': for dec.More() { if err := walk(); err != nil { return err } } end, err := dec.Token() if err != nil { return err } if end != json.Delim(']') { return errors.New("invalid JSON array") } } return nil } if err := walk(); err != nil { return fmt.Errorf("invalid JSON: %w", err) } var trailing any if err := dec.Decode(&trailing); err != io.EOF { if err == nil { return errors.New("JSON contains trailing data") } return fmt.Errorf("invalid JSON trailing data: %w", err) } return nil } func RequiredAllowlist() []string { return append([]string(nil), requiredAllowlist...) } func (m Manifest) SortedCapabilities() []string { out := append([]string(nil), m.Capabilities...) sort.Strings(out) return out } // EvaluateCompatibility applies the V1 rule that an untested Core is compatible // but must remain disabled until an administrator explicitly accepts it. func (m Manifest) EvaluateCompatibility(coreVersion string) Compatibility { coreVersion = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(coreVersion), "sub2api-"), "v") baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(m.CoreAPIBaseline), "sub2api-"), "v") if coreVersion == "" || baseline == "" || coreVersion != baseline { return Compatibility{Status: "incompatible", Message: "Core version does not match plugin baseline"} } tested := false for _, version := range m.TestedCoreVersions { v := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(version), "sub2api-"), "v") if v == coreVersion { tested = true break } } if !tested { return Compatibility{Compatible: true, Status: "untested", Message: "Core version is compatible but not tested"} } return Compatibility{Compatible: true, Tested: true, Status: "compatible", Message: "Core version is tested"} }