diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index db057837..09e2b85b 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -35,6 +35,9 @@ jobs: aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com - name: Build + env: + ROOT_CA_1: ${{ secrets.YAO_ROOT_CA_1_PEM }} + ROOT_CA_2: ${{ secrets.YAO_ROOT_CA_2_PEM }} run: | export PATH=$PATH:/github/home/go/bin @@ -48,6 +51,16 @@ jobs: git clone https://github.com/yaoapp/yao-init.git /app/yao-init git clone https://github.com/yaoapp/yao.git /app/yao + # Inject commercial license root certificates from GitHub Secrets + if [ -n "$ROOT_CA_1" ]; then + echo "$ROOT_CA_1" | base64 -d > /app/yao/commercial/roots/root-ca-1.pem + echo "Injected root-ca-1.pem" + fi + if [ -n "$ROOT_CA_2" ]; then + echo "$ROOT_CA_2" | base64 -d > /app/yao/commercial/roots/root-ca-2.pem + echo "Injected root-ca-2.pem" + fi + # Extract libv8 files=$(find /app/v8go -name "libv8*.zip") for file in $files; do diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index a6846134..d64835ef 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -105,6 +105,20 @@ jobs: rm -f share/const.go.bak grep 'const VERSION' share/const.go + - name: Inject License Root Certificates + env: + ROOT_CA_1: ${{ secrets.YAO_ROOT_CA_1_PEM }} + ROOT_CA_2: ${{ secrets.YAO_ROOT_CA_2_PEM }} + run: | + if [ -n "$ROOT_CA_1" ]; then + echo "$ROOT_CA_1" | base64 -d > commercial/roots/root-ca-1.pem + echo "Injected root-ca-1.pem" + fi + if [ -n "$ROOT_CA_2" ]; then + echo "$ROOT_CA_2" | base64 -d > commercial/roots/root-ca-2.pem + echo "Injected root-ca-2.pem" + fi + - name: Setup Go uses: actions/setup-go@v5 with: diff --git a/cmd/inspect.go b/cmd/inspect.go index 9c969e33..1cf7ef11 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/yaoapp/kun/maps" "github.com/yaoapp/kun/utils" + "github.com/yaoapp/yao/commercial" "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/engine" "github.com/yaoapp/yao/share" @@ -20,6 +21,7 @@ var inspectCmd = &cobra.Command{ "version": share.VERSION, "config": config.Conf, } + res["license"] = commercial.License if share.Tools != nil { res["tools"] = share.Tools } diff --git a/commercial/cmd/generate-test-cert/main.go b/commercial/cmd/generate-test-cert/main.go new file mode 100644 index 00000000..b7a7e023 --- /dev/null +++ b/commercial/cmd/generate-test-cert/main.go @@ -0,0 +1,121 @@ +// Command generate-test-cert creates a test root CA and a license certificate +// for local development and testing. +// +// Usage: +// +// go run ./commercial/cmd/generate-test-cert \ +// -out-cert /path/to/yao-dev-app/license.pem \ +// -out-root-ca ./commercial/roots/root-ca-1.pem +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "fmt" + "math/big" + "os" + "time" + + "github.com/yaoapp/yao/commercial" +) + +func main() { + outCert := flag.String("out-cert", "license.pem", "path to write the license certificate PEM") + outRootCA := flag.String("out-root-ca", "", "path to write the root CA PEM (optional; for injecting into commercial/roots/)") + flag.Parse() + + rootKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + fmt.Fprintf(os.Stderr, "generate root key: %v\n", err) + os.Exit(1) + } + + rootSerial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + rootTmpl := &x509.Certificate{ + SerialNumber: rootSerial, + Subject: pkix.Name{ + CommonName: "Yao Test Root CA", + Organization: []string{"Infinite Wisdom Software"}, + Country: []string{"CN"}, + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 1, + } + + rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey) + if err != nil { + fmt.Fprintf(os.Stderr, "create root cert: %v\n", err) + os.Exit(1) + } + + rootCert, _ := x509.ParseCertificate(rootDER) + rootPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}) + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + fmt.Fprintf(os.Stderr, "generate leaf key: %v\n", err) + os.Exit(1) + } + + leafTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(20001), + Subject: pkix.Name{ + CommonName: "Yao Dev App", + Organization: []string{"Dev Testing"}, + Country: []string{"CN"}, + }, + EmailAddresses: []string{"dev@yaoapps.com"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + ExtraExtensions: []pkix.Extension{ + {Id: commercial.OIDProduct, Value: []byte("yao,tai")}, + {Id: commercial.OIDEdition, Value: []byte("enterprise")}, + {Id: commercial.OIDMaxUsers, Value: []byte("0")}, + {Id: commercial.OIDMaxTaiNodes, Value: []byte("0")}, + {Id: commercial.OIDMaxAgents, Value: []byte("0")}, + {Id: commercial.OIDMaxSandboxes, Value: []byte("0")}, + {Id: commercial.OIDMaxAPIRPM, Value: []byte("0")}, + {Id: commercial.OIDMaxStorageGB, Value: []byte("0")}, + {Id: commercial.OIDAllowBrandingRemoval, Value: []byte("true")}, + {Id: commercial.OIDAllowWhiteLabel, Value: []byte("true")}, + {Id: commercial.OIDAllowMultiTenant, Value: []byte("true")}, + {Id: commercial.OIDAllowCustomDomain, Value: []byte("true")}, + {Id: commercial.OIDAllowHostExec, Value: []byte("true")}, + {Id: commercial.OIDAllowSSO, Value: []byte("true")}, + {Id: commercial.OIDSupportLevel, Value: []byte("dedicated")}, + }, + } + + leafDER, err := x509.CreateCertificate(rand.Reader, leafTmpl, rootCert, &leafKey.PublicKey, rootKey) + if err != nil { + fmt.Fprintf(os.Stderr, "create leaf cert: %v\n", err) + os.Exit(1) + } + + leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}) + + if err := os.WriteFile(*outCert, leafPEM, 0644); err != nil { + fmt.Fprintf(os.Stderr, "write cert: %v\n", err) + os.Exit(1) + } + fmt.Printf("Wrote license certificate to %s\n", *outCert) + + if *outRootCA != "" { + if err := os.WriteFile(*outRootCA, rootPEM, 0644); err != nil { + fmt.Fprintf(os.Stderr, "write root CA: %v\n", err) + os.Exit(1) + } + fmt.Printf("Wrote root CA to %s\n", *outRootCA) + } +} diff --git a/commercial/commercial.go b/commercial/commercial.go new file mode 100644 index 00000000..e320be5b --- /dev/null +++ b/commercial/commercial.go @@ -0,0 +1,262 @@ +package commercial + +import ( + "crypto/x509" + "encoding/asn1" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const envLicenseCert = "YAO_LICENSE_CERT" + +// Load discovers and verifies the commercial license certificate, +// writing the result into the global License variable. +// It never returns an error — failures degrade to community defaults. +func Load(appRoot, product string) { + License = DefaultLicense() + License.Product = []string{product} + + pemData, source := findCert(appRoot) + if pemData == nil { + log.Printf("[License] No license certificate found, running with community defaults") + return + } + + info, err := verify(pemData, product) + if err != nil { + License.Source = source + License.Error = err.Error() + log.Printf("[License] %v — running with community defaults", err) + return + } + + info.Source = source + info.LoadedAt = time.Now().Unix() + License = *info + + if License.Valid { + remaining := time.Until(time.Unix(License.NotAfter, 0)) + log.Printf("[License] Loaded: %s (%s) — valid until %s", + License.LicenseeName, License.Edition, + time.Unix(License.NotAfter, 0).UTC().Format("2006-01-02")) + if remaining < 90*24*time.Hour { + log.Printf("[License] WARNING: Certificate expires in %d days", int(remaining.Hours()/24)) + } + } +} + +// findCert locates the license PEM data. +// Search order: +// 1. YAO_LICENSE_CERT env (PEM content or file path) +// 2. /license.pem +// 3. /certs/license.pem +func findCert(appRoot string) (pemData []byte, source string) { + if v := os.Getenv(envLicenseCert); v != "" { + if strings.HasPrefix(v, "-----BEGIN") { + return []byte(v), "env" + } + data, err := os.ReadFile(v) + if err == nil { + return data, "env" + } + log.Printf("[License] env %s points to unreadable file: %v", envLicenseCert, err) + } + + candidates := []string{ + filepath.Join(appRoot, "license.pem"), + filepath.Join(appRoot, "certs", "license.pem"), + } + for _, path := range candidates { + data, err := os.ReadFile(path) + if err == nil { + return data, "file" + } + } + return nil, "none" +} + +// verify parses PEM data, validates the certificate chain against built-in +// roots, checks revocation, time validity, product scope, and extracts +// custom extension fields. +func verify(pemData []byte, product string) (*LicenseInfo, error) { + certs, err := ParsePEMChain(pemData) + if err != nil { + return nil, fmt.Errorf("parse PEM: %w", err) + } + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates found in PEM data") + } + + leaf := certs[0] + + pool := RootPool() + if pool == nil { + return nil, fmt.Errorf("no root certificates available (development build)") + } + + // Verify the trust chain with a synthetic time within the leaf's validity + // window. This lets us extract structured info from expired/future + // certificates instead of returning an opaque x509 error. + // We use NotAfter-1s (just before expiry) to maximize overlap with CA validity. + opts := x509.VerifyOptions{ + Roots: pool, + CurrentTime: leaf.NotAfter.Add(-time.Second), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + + if len(certs) > 1 { + intermediates := x509.NewCertPool() + for _, c := range certs[1:] { + intermediates.AddCert(c) + } + opts.Intermediates = intermediates + } + + if _, err := leaf.Verify(opts); err != nil { + return nil, fmt.Errorf("certificate verification failed: %w", err) + } + + if IsRevoked(leaf.SerialNumber) { + return nil, fmt.Errorf("certificate serial %s has been revoked", leaf.SerialNumber.Text(16)) + } + + info := extractIdentity(leaf) + parseExtensions(leaf, info) + + now := time.Now() + if now.Before(leaf.NotBefore) { + info.Valid = false + info.Error = fmt.Sprintf("certificate not yet valid (starts %s)", + leaf.NotBefore.UTC().Format("2006-01-02")) + return info, nil + } + if now.After(leaf.NotAfter) { + info.Valid = false + info.IsExpired = true + info.Error = fmt.Sprintf("certificate expired on %s", + leaf.NotAfter.UTC().Format("2006-01-02")) + return info, nil + } + + if !info.HasProduct(product) { + info.Valid = false + info.Error = fmt.Sprintf("certificate not licensed for product %q (licensed: %v)", + product, info.Product) + return info, nil + } + + info.Valid = true + return info, nil +} + +func extractIdentity(cert *x509.Certificate) *LicenseInfo { + info := &LicenseInfo{ + LicenseeName: cert.Subject.CommonName, + SerialNumber: cert.SerialNumber.Text(16), + NotBefore: cert.NotBefore.Unix(), + NotAfter: cert.NotAfter.Unix(), + Issuer: cert.Issuer.CommonName, + Edition: "community", + Product: []string{}, + Permissions: Permissions{SupportLevel: "none"}, + } + if len(cert.Subject.Organization) > 0 { + info.LicenseeOrg = cert.Subject.Organization[0] + } + if len(cert.Subject.Country) > 0 { + info.LicenseeCountry = cert.Subject.Country[0] + } + if len(cert.EmailAddresses) > 0 { + info.LicenseeEmail = cert.EmailAddresses[0] + } + return info +} + +func parseExtensions(cert *x509.Certificate, info *LicenseInfo) { + for _, ext := range cert.Extensions { + val := string(ext.Value) + + switch { + // Scope + case ext.Id.Equal(OIDProduct): + info.Product = splitCSV(val) + case ext.Id.Equal(OIDEdition): + info.Edition = val + case ext.Id.Equal(OIDEnv): + if val != "" { + info.Env = splitCSV(val) + } + case ext.Id.Equal(OIDDomain): + info.Domain = val + case ext.Id.Equal(OIDAppID): + info.AppID = val + + // Quota + case ext.Id.Equal(OIDMaxUsers): + info.MaxUsers = atoi(val) + case ext.Id.Equal(OIDMaxTaiNodes): + info.MaxTaiNodes = atoi(val) + case ext.Id.Equal(OIDMaxAgents): + info.MaxAgents = atoi(val) + case ext.Id.Equal(OIDMaxSandboxes): + info.MaxSandboxes = atoi(val) + case ext.Id.Equal(OIDMaxAPIRPM): + info.MaxAPIRPM = atoi(val) + case ext.Id.Equal(OIDMaxStorageGB): + info.MaxStorageGB = atoi(val) + + // Permissions + case ext.Id.Equal(OIDAllowBrandingRemoval): + info.Permissions.AllowBrandingRemoval = toBool(val) + case ext.Id.Equal(OIDAllowWhiteLabel): + info.Permissions.AllowWhiteLabel = toBool(val) + case ext.Id.Equal(OIDAllowMultiTenant): + info.Permissions.AllowMultiTenant = toBool(val) + case ext.Id.Equal(OIDAllowCustomDomain): + info.Permissions.AllowCustomDomain = toBool(val) + case ext.Id.Equal(OIDAllowHostExec): + info.Permissions.AllowHostExec = toBool(val) + case ext.Id.Equal(OIDAllowSSO): + info.Permissions.AllowSSO = toBool(val) + case ext.Id.Equal(OIDSupportLevel): + info.Permissions.SupportLevel = val + } + } +} + +// MakeExtension creates a pkix.Extension for embedding in a certificate. +func MakeExtension(oid asn1.ObjectIdentifier, value string) ExtensionValue { + return ExtensionValue{OID: oid, Value: value} +} + +// ExtensionValue pairs an OID with its string value for certificate generation. +type ExtensionValue struct { + OID asn1.ObjectIdentifier + Value string +} + +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + var result []string + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + result = append(result, t) + } + } + return result +} + +func atoi(s string) int { + n, _ := strconv.Atoi(strings.TrimSpace(s)) + return n +} + +func toBool(s string) bool { + s = strings.TrimSpace(strings.ToLower(s)) + return s == "true" || s == "1" || s == "yes" +} diff --git a/commercial/commercial_test.go b/commercial/commercial_test.go new file mode 100644 index 00000000..80cc58f4 --- /dev/null +++ b/commercial/commercial_test.go @@ -0,0 +1,446 @@ +package commercial + +import ( + "crypto/x509" + "math/big" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// withTestRootPool temporarily replaces the root pool and revocation list +// for testing, restoring originals on cleanup. +func withTestRootPool(t *testing.T, ca *testCA, revoked []*big.Int) { + t.Helper() + + origPool := rootPool + origOnce := rootPoolOnce + origSerials := revokedSerials + origRevokedOnce := revokedOnce + + pool := x509.NewCertPool() + pool.AddCert(ca.Cert) + rootPool = pool + rootPoolOnce = sync.Once{} + rootPoolOnce.Do(func() {}) // mark as done so RootPool() returns our pool + revokedSerials = revoked + revokedOnce = sync.Once{} + revokedOnce.Do(func() {}) // mark as done + + t.Cleanup(func() { + rootPool = origPool + rootPoolOnce = origOnce + revokedSerials = origSerials + revokedOnce = origRevokedOnce + }) +} + +func TestNoCertificate(t *testing.T) { + dir := t.TempDir() + License = DefaultLicense() + Load(dir, "yao") + + if License.Valid { + t.Fatal("expected Valid=false with no certificate") + } + if License.Source != "none" { + t.Fatalf("expected Source=none, got %s", License.Source) + } + if License.Edition != "community" { + t.Fatalf("expected Edition=community, got %s", License.Edition) + } + if License.MaxUsers != 100 { + t.Fatalf("expected MaxUsers=100, got %d", License.MaxUsers) + } +} + +func TestValidCertificate(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + opts := defaultLicenseOpts() + leaf, err := generateLicenseCert(root, opts) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644); err != nil { + t.Fatal(err) + } + + Load(dir, "yao") + + if !License.Valid { + t.Fatalf("expected Valid=true, got error: %s", License.Error) + } + if License.Source != "file" { + t.Fatalf("expected Source=file, got %s", License.Source) + } + if License.Edition != "pro" { + t.Fatalf("expected Edition=pro, got %s", License.Edition) + } + if License.LicenseeName != "Test Corp" { + t.Fatalf("expected LicenseeName=Test Corp, got %s", License.LicenseeName) + } + if License.MaxUsers != 500 { + t.Fatalf("expected MaxUsers=500, got %d", License.MaxUsers) + } +} + +func TestExpiredCertificate(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + opts := defaultLicenseOpts() + opts.NotBefore = time.Now().Add(-30 * time.Minute) + opts.NotAfter = time.Now().Add(-1 * time.Minute) + leaf, err := generateLicenseCert(root, opts) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644) + + Load(dir, "yao") + + if License.Valid { + t.Fatal("expected Valid=false for expired certificate") + } + if !License.IsExpired { + t.Fatal("expected IsExpired=true") + } +} + +func TestNotYetValidCertificate(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + opts := defaultLicenseOpts() + opts.NotBefore = time.Now().Add(24 * time.Hour) + opts.NotAfter = time.Now().Add(365 * 24 * time.Hour) + leaf, err := generateLicenseCert(root, opts) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644) + + Load(dir, "yao") + + if License.Valid { + t.Fatal("expected Valid=false for not-yet-valid certificate") + } + if License.IsExpired { + t.Fatal("expected IsExpired=false for future certificate") + } +} + +func TestTamperedCertificate(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + // Generate with a different root (not in our pool) to simulate tampering + fakeRoot, err := generateRootCA("Fake Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + + opts := defaultLicenseOpts() + leaf, err := generateLicenseCert(fakeRoot, opts) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644) + + Load(dir, "yao") + + if License.Valid { + t.Fatal("expected Valid=false for tampered certificate") + } + if License.Error == "" { + t.Fatal("expected Error to be set") + } +} + +func TestWrongProduct(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + opts := defaultLicenseOpts() + // Only licensed for "tai", not "yao" + for i, ext := range opts.Extensions { + if ext.OID.Equal(OIDProduct) { + opts.Extensions[i].Value = "tai" + } + } + leaf, err := generateLicenseCert(root, opts) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644) + + Load(dir, "yao") + + if License.Valid { + t.Fatal("expected Valid=false for wrong product") + } +} + +func TestCertificateChainWithIntermediate(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + intermediate, err := generateIntermediateCA("Test Intermediate CA", 3*365*24*time.Hour, root) + if err != nil { + t.Fatal(err) + } + + opts := defaultLicenseOpts() + leaf, err := generateLicenseCert(intermediate, opts) + if err != nil { + t.Fatal(err) + } + + // PEM chain: leaf + intermediate + chainPEM := append(leaf.CertPEM, intermediate.CertPEM...) + + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "license.pem"), chainPEM, 0644) + + Load(dir, "yao") + + if !License.Valid { + t.Fatalf("expected Valid=true with intermediate chain, got error: %s", License.Error) + } +} + +func TestRevokedCertificate(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + + opts := defaultLicenseOpts() + opts.Serial = big.NewInt(99999) + leaf, err := generateLicenseCert(root, opts) + if err != nil { + t.Fatal(err) + } + + withTestRootPool(t, root, []*big.Int{big.NewInt(99999)}) + + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644) + + Load(dir, "yao") + + if License.Valid { + t.Fatal("expected Valid=false for revoked certificate") + } +} + +func TestEnvVarLoading(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + opts := defaultLicenseOpts() + leaf, err := generateLicenseCert(root, opts) + if err != nil { + t.Fatal(err) + } + + // Write cert to a temp file and point env var to it + certFile := filepath.Join(t.TempDir(), "test-license.pem") + os.WriteFile(certFile, leaf.CertPEM, 0644) + + t.Setenv(envLicenseCert, certFile) + + Load(t.TempDir(), "yao") + + if !License.Valid { + t.Fatalf("expected Valid=true via env, got error: %s", License.Error) + } + if License.Source != "env" { + t.Fatalf("expected Source=env, got %s", License.Source) + } +} + +func TestAllExtensions(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + opts := defaultLicenseOpts() + opts.Extensions = []ExtensionValue{ + {OID: OIDProduct, Value: "yao,tai"}, + {OID: OIDEdition, Value: "enterprise"}, + {OID: OIDEnv, Value: "production,staging"}, + {OID: OIDDomain, Value: "*.acme.com"}, + {OID: OIDAppID, Value: "acme-crm"}, + {OID: OIDMaxUsers, Value: "0"}, + {OID: OIDMaxTaiNodes, Value: "0"}, + {OID: OIDMaxAgents, Value: "0"}, + {OID: OIDMaxSandboxes, Value: "0"}, + {OID: OIDMaxAPIRPM, Value: "0"}, + {OID: OIDMaxStorageGB, Value: "0"}, + {OID: OIDAllowBrandingRemoval, Value: "true"}, + {OID: OIDAllowWhiteLabel, Value: "true"}, + {OID: OIDAllowMultiTenant, Value: "true"}, + {OID: OIDAllowCustomDomain, Value: "true"}, + {OID: OIDAllowHostExec, Value: "true"}, + {OID: OIDAllowSSO, Value: "true"}, + {OID: OIDSupportLevel, Value: "dedicated"}, + } + + leaf, err := generateLicenseCert(root, opts) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644) + + Load(dir, "yao") + + if !License.Valid { + t.Fatalf("expected Valid=true, got error: %s", License.Error) + } + if License.Edition != "enterprise" { + t.Fatalf("expected enterprise, got %s", License.Edition) + } + if !License.HasProduct("yao") || !License.HasProduct("tai") { + t.Fatalf("expected product yao,tai, got %v", License.Product) + } + if len(License.Env) != 2 { + t.Fatalf("expected 2 envs, got %v", License.Env) + } + if License.Domain != "*.acme.com" { + t.Fatalf("expected domain *.acme.com, got %s", License.Domain) + } + if License.AppID != "acme-crm" { + t.Fatalf("expected app_id acme-crm, got %s", License.AppID) + } + if License.MaxUsers != 0 { + t.Fatalf("expected MaxUsers=0 (unlimited), got %d", License.MaxUsers) + } + if !License.Permissions.AllowBrandingRemoval { + t.Fatal("expected AllowBrandingRemoval=true") + } + if !License.Permissions.AllowWhiteLabel { + t.Fatal("expected AllowWhiteLabel=true") + } + if !License.Permissions.AllowMultiTenant { + t.Fatal("expected AllowMultiTenant=true") + } + if !License.Permissions.AllowSSO { + t.Fatal("expected AllowSSO=true") + } + if License.Permissions.SupportLevel != "dedicated" { + t.Fatalf("expected SupportLevel=dedicated, got %s", License.Permissions.SupportLevel) + } +} + +func TestDefaultLicenseAndHelpers(t *testing.T) { + def := DefaultLicense() + + if def.Valid { + t.Fatal("default should not be Valid") + } + if def.Edition != "community" { + t.Fatalf("expected community, got %s", def.Edition) + } + if !def.IsLevel("community") { + t.Fatal("community should satisfy IsLevel(community)") + } + if def.IsLevel("starter") { + t.Fatal("community should not satisfy IsLevel(starter)") + } + if def.IsLevel("pro") { + t.Fatal("community should not satisfy IsLevel(pro)") + } + + pro := LicenseInfo{Edition: "pro"} + if !pro.IsLevel("community") { + t.Fatal("pro should satisfy IsLevel(community)") + } + if !pro.IsLevel("starter") { + t.Fatal("pro should satisfy IsLevel(starter)") + } + if !pro.IsLevel("pro") { + t.Fatal("pro should satisfy IsLevel(pro)") + } + if pro.IsLevel("enterprise") { + t.Fatal("pro should not satisfy IsLevel(enterprise)") + } + + multi := LicenseInfo{Product: []string{"yao", "tai"}} + if !multi.HasProduct("yao") { + t.Fatal("expected HasProduct(yao)=true") + } + if !multi.HasProduct("tai") { + t.Fatal("expected HasProduct(tai)=true") + } + if multi.HasProduct("other") { + t.Fatal("expected HasProduct(other)=false") + } +} + +func TestCertsSubdirectoryFallback(t *testing.T) { + root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour) + if err != nil { + t.Fatal(err) + } + withTestRootPool(t, root, nil) + + opts := defaultLicenseOpts() + leaf, err := generateLicenseCert(root, opts) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + certsDir := filepath.Join(dir, "certs") + os.MkdirAll(certsDir, 0755) + os.WriteFile(filepath.Join(certsDir, "license.pem"), leaf.CertPEM, 0644) + + Load(dir, "yao") + + if !License.Valid { + t.Fatalf("expected Valid=true from certs/ fallback, got error: %s", License.Error) + } + if License.Source != "file" { + t.Fatalf("expected Source=file, got %s", License.Source) + } +} diff --git a/commercial/oid.go b/commercial/oid.go new file mode 100644 index 00000000..7567f4e8 --- /dev/null +++ b/commercial/oid.go @@ -0,0 +1,38 @@ +package commercial + +import "encoding/asn1" + +// OID prefix: 1.3.6.1.4.1.15099.1 +// 15099 is Yao's internal port number, used as a recognizable enterprise number +// for this closed-loop certificate system. Not registered with IANA. + +var ( + // Scope + OIDProduct = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 1} + OIDEdition = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 2} + OIDEnv = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 3} + OIDDomain = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 4} + OIDAppID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 5} + + // Quota + OIDMaxUsers = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 1} + OIDMaxTaiNodes = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 2} + OIDMaxAgents = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 3} + OIDMaxSandboxes = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 4} + OIDMaxAPIRPM = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 5} + OIDMaxStorageGB = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 6} + + // Permissions + OIDAllowBrandingRemoval = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 1} + OIDAllowWhiteLabel = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 2} + OIDAllowMultiTenant = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 3} + OIDAllowCustomDomain = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 4} + OIDAllowHostExec = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 5} + OIDAllowSSO = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 6} + OIDSupportLevel = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 7} + + // Issuance (internal tracking) + OIDIssuerID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 1} + OIDOrderID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 2} + OIDNote = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 3} +) diff --git a/commercial/roots.go b/commercial/roots.go new file mode 100644 index 00000000..03d0dfd0 --- /dev/null +++ b/commercial/roots.go @@ -0,0 +1,98 @@ +package commercial + +import ( + "crypto/x509" + _ "embed" + "encoding/json" + "encoding/pem" + "math/big" + "sync" +) + +//go:embed roots/root-ca-1.pem +var rootCA1PEM []byte + +//go:embed roots/root-ca-2.pem +var rootCA2PEM []byte + +//go:embed roots/revoked.json +var revokedJSON []byte + +var ( + rootPoolOnce sync.Once + rootPool *x509.CertPool + + revokedOnce sync.Once + revokedSerials []*big.Int +) + +// RootPool returns the built-in root certificate pool (primary + backup). +// Returns nil if neither root certificate could be parsed (e.g. dev placeholder). +func RootPool() *x509.CertPool { + rootPoolOnce.Do(func() { + pool := x509.NewCertPool() + added := false + for _, pemData := range [][]byte{rootCA1PEM, rootCA2PEM} { + if pool.AppendCertsFromPEM(pemData) { + added = true + } + } + if added { + rootPool = pool + } + }) + return rootPool +} + +// RevokedSerials returns the list of revoked certificate serial numbers +// embedded in the binary. +func RevokedSerials() []*big.Int { + revokedOnce.Do(func() { + var data struct { + Serials []string `json:"serials"` + } + if err := json.Unmarshal(revokedJSON, &data); err != nil { + return + } + for _, s := range data.Serials { + n := new(big.Int) + if _, ok := n.SetString(s, 0); ok { + revokedSerials = append(revokedSerials, n) + } + } + }) + return revokedSerials +} + +// IsRevoked checks whether the given serial number is in the revocation list. +func IsRevoked(serial *big.Int) bool { + for _, s := range RevokedSerials() { + if s.Cmp(serial) == 0 { + return true + } + } + return false +} + +// ParsePEMChain parses a PEM bundle into a list of x509 certificates. +// The first certificate is treated as the leaf; remaining are intermediates. +func ParsePEMChain(pemData []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + rest := pemData + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + certs = append(certs, cert) + } + return certs, nil +} diff --git a/commercial/roots/revoked.json b/commercial/roots/revoked.json new file mode 100644 index 00000000..113c7cb8 --- /dev/null +++ b/commercial/roots/revoked.json @@ -0,0 +1 @@ +{"serials": []} diff --git a/commercial/roots/root-ca-1.pem b/commercial/roots/root-ca-1.pem new file mode 100644 index 00000000..d8d9cd9c --- /dev/null +++ b/commercial/roots/root-ca-1.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICFzCCAZygAwIBAgIQYJYI6zEyHfyDlfFW2p4NHDAKBggqhkjOPQQDAzBLMQsw +CQYDVQQGEwJDTjEhMB8GA1UEChMYSW5maW5pdGUgV2lzZG9tIFNvZnR3YXJlMRkw +FwYDVQQDExBZYW8gVGVzdCBSb290IENBMB4XDTI2MDMyNzAzMzYxMVoXDTM2MDMy +NDA0MzYxMVowSzELMAkGA1UEBhMCQ04xITAfBgNVBAoTGEluZmluaXRlIFdpc2Rv +bSBTb2Z0d2FyZTEZMBcGA1UEAxMQWWFvIFRlc3QgUm9vdCBDQTB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABNe4Y3nl17lCgK3dZaGFYPdeutdm/hdprjwynJPfGBnw84oE +UBIgm7Nd5hpM57xZak9oH4ol58sOTxjWL492JWX+jU9pRUYn042HA4imSmmrxzP3 +83m8Kn9VcYSEKPJzmKNFMEMwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYB +Af8CAQEwHQYDVR0OBBYEFGh4E/o55y4xR3qa7ZUSIVoxpHdlMAoGCCqGSM49BAMD +A2kAMGYCMQDLhwvg8SDmMiwHCejwtLBozFLcdkys/MoFVnueUE1RAQo1G0CitB/y +UJaYwCnV3j0CMQDe09TLfSA/69XDsv4ueNq9UtY9+ysEzkD2zxupOL1MYSPGNN0a +/ct7CLnLDOnRsN8= +-----END CERTIFICATE----- diff --git a/commercial/roots/root-ca-2.pem b/commercial/roots/root-ca-2.pem new file mode 100644 index 00000000..9e45480f --- /dev/null +++ b/commercial/roots/root-ca-2.pem @@ -0,0 +1,4 @@ +-----BEGIN PLACEHOLDER----- +This is a development placeholder. The real backup root certificate will be +injected by the release workflow via GitHub Secrets before compilation. +-----END PLACEHOLDER----- diff --git a/commercial/testhelper_test.go b/commercial/testhelper_test.go new file mode 100644 index 00000000..038a805a --- /dev/null +++ b/commercial/testhelper_test.go @@ -0,0 +1,177 @@ +package commercial + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "time" +) + +// testCA holds a generated CA certificate and its private key. +type testCA struct { + Cert *x509.Certificate + Key *ecdsa.PrivateKey + CertPEM []byte +} + +// testLicenseCert holds a generated leaf (license) certificate. +type testLicenseCert struct { + Cert *x509.Certificate + CertPEM []byte +} + +// generateRootCA creates a self-signed ECDSA P-384 root CA for testing. +func generateRootCA(cn string, validity time.Duration) (*testCA, error) { + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, err + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: cn, + Organization: []string{"Infinite Wisdom Software"}, + Country: []string{"CN"}, + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(validity), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 1, + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, err + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + return &testCA{Cert: cert, Key: key, CertPEM: certPEM}, nil +} + +// generateIntermediateCA creates an intermediate CA signed by the given parent. +func generateIntermediateCA(cn string, validity time.Duration, parent *testCA) (*testCA, error) { + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, err + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: cn, + Organization: []string{"Test Partner Inc."}, + Country: []string{"US"}, + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(validity), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + MaxPathLenZero: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, parent.Cert, &key.PublicKey, parent.Key) + if err != nil { + return nil, err + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + return &testCA{Cert: cert, Key: key, CertPEM: certPEM}, nil +} + +// licenseOpts configures a test license certificate. +type licenseOpts struct { + CN string + Org string + Country string + Email string + NotBefore time.Time + NotAfter time.Time + Serial *big.Int + Extensions []ExtensionValue +} + +func defaultLicenseOpts() licenseOpts { + return licenseOpts{ + CN: "Test Corp", + Org: "Test Inc.", + Country: "CN", + Email: "test@example.com", + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + Serial: big.NewInt(10042), + Extensions: []ExtensionValue{ + {OID: OIDProduct, Value: "yao"}, + {OID: OIDEdition, Value: "pro"}, + {OID: OIDMaxUsers, Value: "500"}, + {OID: OIDMaxTaiNodes, Value: "10"}, + {OID: OIDMaxAgents, Value: "50"}, + {OID: OIDMaxSandboxes, Value: "20"}, + {OID: OIDMaxAPIRPM, Value: "10000"}, + {OID: OIDMaxStorageGB, Value: "100"}, + {OID: OIDSupportLevel, Value: "priority"}, + }, + } +} + +// generateLicenseCert creates a leaf license certificate signed by the given CA. +func generateLicenseCert(signer *testCA, opts licenseOpts) (*testLicenseCert, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + tmpl := &x509.Certificate{ + SerialNumber: opts.Serial, + Subject: pkix.Name{ + CommonName: opts.CN, + Organization: []string{opts.Org}, + Country: []string{opts.Country}, + }, + EmailAddresses: []string{opts.Email}, + NotBefore: opts.NotBefore, + NotAfter: opts.NotAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + + for _, ext := range opts.Extensions { + tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, pkix.Extension{ + Id: ext.OID, + Value: []byte(ext.Value), + }) + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, signer.Cert, &key.PublicKey, signer.Key) + if err != nil { + return nil, err + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + return &testLicenseCert{Cert: cert, CertPEM: certPEM}, nil +} diff --git a/commercial/types.go b/commercial/types.go new file mode 100644 index 00000000..a3e42a4d --- /dev/null +++ b/commercial/types.go @@ -0,0 +1,114 @@ +package commercial + +import "time" + +// License is the global commercial license state, populated by Load(). +// Read-only after initialization; all modules may read without synchronization. +var License LicenseInfo + +// LicenseInfo holds the parsed result of a commercial license certificate. +type LicenseInfo struct { + Valid bool `json:"valid"` + Source string `json:"source"` // "none" | "file" | "env" + LoadedAt int64 `json:"loaded_at"` + Error string `json:"error,omitempty"` + + // Identity (from X.509 Subject) + LicenseeName string `json:"licensee_name"` + LicenseeOrg string `json:"licensee_org"` + LicenseeCountry string `json:"licensee_country,omitempty"` + LicenseeEmail string `json:"licensee_email,omitempty"` + SerialNumber string `json:"serial_number"` + NotBefore int64 `json:"not_before"` + NotAfter int64 `json:"not_after"` + IsExpired bool `json:"is_expired"` + Issuer string `json:"issuer"` + + // Scope + Product []string `json:"product"` + Edition string `json:"edition"` // "community" | "starter" | "pro" | "enterprise" + Env []string `json:"env,omitempty"` + Domain string `json:"domain,omitempty"` + AppID string `json:"app_id,omitempty"` + + // Quota (0 = unlimited) + MaxUsers int `json:"max_users"` + MaxTaiNodes int `json:"max_tai_nodes"` + MaxAgents int `json:"max_agents"` + MaxSandboxes int `json:"max_sandboxes"` + MaxAPIRPM int `json:"max_api_rpm"` + MaxStorageGB int `json:"max_storage_gb"` + + // Permissions + Permissions Permissions `json:"permissions"` +} + +// Permissions controls feature switches. +type Permissions struct { + AllowBrandingRemoval bool `json:"allow_branding_removal"` + AllowWhiteLabel bool `json:"allow_white_label"` + AllowMultiTenant bool `json:"allow_multi_tenant"` + AllowCustomDomain bool `json:"allow_custom_domain"` + AllowHostExec bool `json:"allow_host_exec"` + AllowSSO bool `json:"allow_sso"` + SupportLevel string `json:"support_level"` // "none" | "email" | "priority" | "dedicated" +} + +// PublicInfo is the subset safe for well-known / public API exposure. +type PublicInfo struct { + Valid bool `json:"valid"` + Edition string `json:"edition"` + NotAfter int64 `json:"not_after,omitempty"` + Product []string `json:"product"` +} + +// DefaultLicense returns community-level defaults when no certificate is present. +func DefaultLicense() LicenseInfo { + return LicenseInfo{ + Source: "none", + LoadedAt: time.Now().Unix(), + Edition: "community", + Product: []string{"yao"}, + MaxUsers: 100, + MaxTaiNodes: 1, + MaxAgents: 3, + MaxSandboxes: 1, + MaxAPIRPM: 1000, + MaxStorageGB: 10, + Permissions: Permissions{ + SupportLevel: "none", + }, + } +} + +// GetPublicInfo returns the public-safe subset of the current license state. +func GetPublicInfo() *PublicInfo { + return &PublicInfo{ + Valid: License.Valid, + Edition: License.Edition, + NotAfter: License.NotAfter, + Product: License.Product, + } +} + +var editionRank = map[string]int{ + "community": 0, + "starter": 1, + "pro": 2, + "enterprise": 3, +} + +// IsLevel reports whether the license meets or exceeds the given minimum edition. +func (l LicenseInfo) IsLevel(minEdition string) bool { + return editionRank[l.Edition] >= editionRank[minEdition] +} + +// HasProduct reports whether the license covers the given product name. +func (l LicenseInfo) HasProduct(product string) bool { + for _, p := range l.Product { + if p == product { + return true + } + } + return false +} diff --git a/engine/load.go b/engine/load.go index d96baf21..baac7bcb 100644 --- a/engine/load.go +++ b/engine/load.go @@ -23,6 +23,7 @@ import ( "github.com/yaoapp/yao/api" "github.com/yaoapp/yao/attachment" "github.com/yaoapp/yao/cert" + "github.com/yaoapp/yao/commercial" "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/connector" "github.com/yaoapp/yao/data" @@ -149,6 +150,12 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string warnings = append(warnings, Warning{Widget: "Registry", Error: err}) } + // Load Commercial License + loadStep("License", func() error { + commercial.Load(cfg.Root, "yao") + return nil + }, callback) + // Load Certs err = loadStep("Cert", func() error { return cert.Load(cfg) diff --git a/openapi/well-known.go b/openapi/well-known.go index bb67c2cd..95acc647 100644 --- a/openapi/well-known.go +++ b/openapi/well-known.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/commercial" "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/share" ) @@ -52,6 +53,9 @@ type YaoMetadata struct { GRPC string `json:"grpc,omitempty"` // gRPC server address (e.g., "127.0.0.1:9099") Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings + // Commercial license + License *commercial.PublicInfo `json:"license,omitempty"` + // Developer information Developer *share.Developer `json:"developer,omitempty"` } @@ -76,6 +80,9 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) { Optional: share.App.Optional, } + // Include license info + metadata.License = commercial.GetPublicInfo() + // Include developer info if available if share.App.Developer.ID != "" || share.App.Developer.Name != "" { metadata.Developer = &share.App.Developer