chore: initialize standalone business plugin repository
Business Plugins CI / check (plugin-admin) (push) Successful in 3m13s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m41s

This commit is contained in:
Qiufeng
2026-08-27 23:36:08 +08:00
commit 5feae3ad41
59 changed files with 8950 additions and 0 deletions
@@ -0,0 +1,414 @@
// 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"}
}
@@ -0,0 +1,119 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func validManifest() Manifest {
return Manifest{
SchemaVersion: 1, PluginID: "qiu.plugin-admin", Name: "Plugin Admin", Version: "1.0.0",
CoreAPIBaseline: "sub2api-0.1.183", Capabilities: []string{"plugin.admin.v1", "diagnostics.v1"},
TestedCoreVersions: []string{"0.1.183"},
Backend: Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"},
UI: UI{Entrypoint: "/admin/", Menu: Menu{ID: "qiu.plugin-admin", Label: "Plugin Admin", Visibility: "admin", SortOrder: 200}},
Publisher: Publisher{KeyID: "publisher-key"}, CoreAPIAllowlist: RequiredAllowlist(),
}
}
func TestValidateManifestAndRejectsUnsafeEntries(t *testing.T) {
m := validManifest()
if err := Validate(m); err != nil {
t.Fatal(err)
}
for name, mutate := range map[string]func(*Manifest){
"duplicate allowlist": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, m.CoreAPIAllowlist[0]) },
"wildcard": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/*") },
"menu mismatch": func(m *Manifest) { m.UI.Menu.ID = "other.plugin" },
"traversal": func(m *Manifest) { m.UI.Entrypoint = "/admin/../secret" },
"entrypoint URL": func(m *Manifest) { m.UI.Entrypoint = "https://example.invalid/ui.js" },
"file drive path": func(m *Manifest) { m.Files = map[string]string{"C:/plugin.js": strings.Repeat("a", 64)} },
"file traversal": func(m *Manifest) { m.Files = map[string]string{"ui/../plugin.js": strings.Repeat("a", 64)} },
} {
t.Run(name, func(t *testing.T) {
candidate := m
mutate(&candidate)
if err := Validate(candidate); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestLoadRejectsUnknownAndTrailingJSON(t *testing.T) {
dir := t.TempDir()
for name, raw := range map[string]string{
"unknown": `{"schema_version":1,"extra":true}`,
"trailing": `{"schema_version":1} {}`,
"duplicate": `{"schema_version":1,"schema_version":1}`,
} {
path := filepath.Join(dir, name+".json")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatalf("%s: expected error", name)
}
}
}
func TestLoadRejectsNestedDuplicateJSONKeys(t *testing.T) {
dir := t.TempDir()
raw := `{"schema_version":1,"backend":{"health_path":"/healthz","health_path":"/readyz"}}`
path := filepath.Join(dir, "nested-duplicate.json")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatal("expected nested duplicate key error")
}
}
func TestSignatureAndKeyID(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
manifestBytes := []byte(`{"schema_version":1}`)
signature := Signature{Algorithm: "ed25519", KeyID: "publisher-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
signatureBytes, err := json.Marshal(signature)
if err != nil {
t.Fatal(err)
}
publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey))
if err := VerifyKeyID(signatureBytes, "publisher-key"); err != nil {
t.Fatal(err)
}
if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil {
t.Fatal(err)
}
if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil {
t.Fatal("expected tamper failure")
}
if err := VerifyKeyID([]byte(strings.TrimSuffix(string(signatureBytes), "}")+"}{}"), "publisher-key"); err == nil {
t.Fatal("expected trailing signature failure")
}
duplicate := []byte(`{"algorithm":"ed25519","algorithm":"ed25519","key_id":"publisher-key","signature":""}`)
if err := VerifyKeyID(duplicate, "publisher-key"); err == nil {
t.Fatal("expected duplicate signature key failure")
}
}
func TestCompatibility(t *testing.T) {
m := validManifest()
if got := m.EvaluateCompatibility("sub2api-0.1.183"); !got.Compatible || !got.Tested || got.Status != "compatible" {
t.Fatalf("got %#v", got)
}
m.TestedCoreVersions = nil
if got := m.EvaluateCompatibility("0.1.183"); !got.Compatible || got.Tested || got.Status != "untested" {
t.Fatalf("got %#v", got)
}
if got := m.EvaluateCompatibility("0.1.184"); got.Compatible || got.Status != "incompatible" {
t.Fatalf("got %#v", got)
}
}