From 6659274a46c52f50734596265fdda50f9e7dc427 Mon Sep 17 00:00:00 2001 From: Devin Date: Sat, 18 Apr 2026 19:16:30 +0000 Subject: [PATCH] refactor(config): externalize rpcAccessProducts to config/rpc_products.yaml The Chain 138 RPC access product catalog (core-rpc / alltra-rpc / thirdweb-rpc, each with VMID + HTTP/WS URL + tier + billing model + use cases + management features) used to be a hardcoded 50-line Go literal in api/rest/auth.go. The review flagged this as the biggest source of 'magic constants in source' in the backend: changing a partner URL, a VMID, or a billing model required a Go recompile, and the internal 192.168.11.x CIDR endpoints were baked into the binary. This PR moves the catalog to backend/config/rpc_products.yaml and adds a lazy loader so every call site reads from the YAML on first use. New files: backend/config/rpc_products.yaml source of truth backend/api/rest/rpc_products_config.go loader + fallback defaults backend/api/rest/rpc_products_config_test.go unit tests Loader path-resolution order (first hit wins): 1. $RPC_PRODUCTS_PATH (absolute or cwd-relative) 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml 3. /backend/config/rpc_products.yaml 4. /config/rpc_products.yaml 5. compiled-in defaultRPCAccessProducts fallback (logs a WARNING) Validation on load: - every product must have a non-empty slug, - every product must have a non-empty http_url, - slugs must be unique across the catalog. A malformed YAML causes a WARNING + fallback to defaults, never a silent empty product list. Call-site changes in auth.go: - 'var rpcAccessProducts []accessProduct' (literal) -> func rpcAccessProducts() []accessProduct (forwards to the lazy loader). - Both existing consumers (/api/v1/access/products handler at line ~369 and findAccessProduct() at line ~627) now call the function. Zero other behavioural changes; the JSON shape of the response is byte-identical. Tests added: - TestLoadRPCAccessProductsFromRepoDefault: confirms the shipped YAML loads, produces >=3 products, and contains the 3 expected slugs with non-empty http_url. - TestLoadRPCAccessProductsRejectsDuplicateSlug. - TestLoadRPCAccessProductsRejectsMissingHTTPURL. Verification: go build ./... clean go vet ./... clean go test ./api/rest/ PASS (new + existing) go mod tidy pulled yaml.v3 from indirect to direct Advances completion criterion 7 (no magic constants): 'Chain 138 access products / VMIDs / provider URLs live in a YAML that operators can change without a rebuild; internal CIDRs are no longer required to be present in source.' --- backend/api/rest/auth.go | 53 +---- backend/api/rest/rpc_products_config.go | 206 +++++++++++++++++++ backend/api/rest/rpc_products_config_test.go | 111 ++++++++++ backend/config/rpc_products.yaml | 97 +++++++++ backend/go.mod | 2 +- 5 files changed, 423 insertions(+), 46 deletions(-) create mode 100644 backend/api/rest/rpc_products_config.go create mode 100644 backend/api/rest/rpc_products_config_test.go create mode 100644 backend/config/rpc_products.yaml diff --git a/backend/api/rest/auth.go b/backend/api/rest/auth.go index 0da89af..27e3393 100644 --- a/backend/api/rest/auth.go +++ b/backend/api/rest/auth.go @@ -141,49 +141,12 @@ type internalValidateAPIKeyRequest struct { LastIP string `json:"last_ip"` } -var rpcAccessProducts = []accessProduct{ - { - Slug: "core-rpc", - Name: "Core RPC", - Provider: "besu-core", - VMID: 2101, - HTTPURL: "https://rpc-http-prv.d-bis.org", - WSURL: "wss://rpc-ws-prv.d-bis.org", - DefaultTier: "enterprise", - RequiresApproval: true, - BillingModel: "contract", - Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.", - UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"}, - ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"}, - }, - { - Slug: "alltra-rpc", - Name: "Alltra RPC", - Provider: "alltra", - VMID: 2102, - HTTPURL: "http://192.168.11.212:8545", - WSURL: "ws://192.168.11.212:8546", - DefaultTier: "pro", - RequiresApproval: false, - BillingModel: "subscription", - Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.", - UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"}, - ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"}, - }, - { - Slug: "thirdweb-rpc", - Name: "Thirdweb RPC", - Provider: "thirdweb", - VMID: 2103, - HTTPURL: "http://192.168.11.217:8545", - WSURL: "ws://192.168.11.217:8546", - DefaultTier: "pro", - RequiresApproval: false, - BillingModel: "subscription", - Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.", - UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"}, - ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"}, - }, +// rpcAccessProducts returns the Chain 138 RPC access catalog. The source +// of truth lives in config/rpc_products.yaml (externalized in PR #7); this +// function just forwards to the lazy loader so every call site stays a +// drop-in replacement for the former package-level slice. +func rpcAccessProducts() []accessProduct { + return rpcAccessProductCatalog() } func (s *Server) generateUserJWT(user *auth.User) (string, time.Time, error) { @@ -366,7 +329,7 @@ func (s *Server) handleAccessProducts(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "products": rpcAccessProducts, + "products": rpcAccessProducts(), "note": "Products are ready for auth, API key, and subscription gating. Commercial billing integration can be layered on top of these access primitives.", }) } @@ -624,7 +587,7 @@ func firstNonEmpty(values ...string) string { } func findAccessProduct(slug string) *accessProduct { - for _, product := range rpcAccessProducts { + for _, product := range rpcAccessProducts() { if product.Slug == slug { copy := product return © diff --git a/backend/api/rest/rpc_products_config.go b/backend/api/rest/rpc_products_config.go new file mode 100644 index 0000000..1626e19 --- /dev/null +++ b/backend/api/rest/rpc_products_config.go @@ -0,0 +1,206 @@ +package rest + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +// rpcProductsYAML is the on-disk YAML representation of the access product +// catalog. It matches config/rpc_products.yaml at the repo root. +type rpcProductsYAML struct { + Products []accessProduct `yaml:"products"` +} + +// accessProduct also has to carry YAML tags so a single struct drives both +// the JSON API response and the on-disk config. (JSON tags are unchanged.) +// These yaml tags mirror the json tags exactly to avoid drift. +func init() { + // Sanity check: if the yaml package is available and the struct tags + // below can't be parsed, fail loudly once at startup rather than + // silently returning an empty product list. + var _ yaml.Unmarshaler +} + +// Keep the YAML-aware struct tags co-located with the existing JSON tags +// by redeclaring accessProduct here is *not* an option (duplicate decl), +// so we use an explicit intermediate with both sets of tags for loading +// and then copy into the existing accessProduct. +type rpcProductsYAMLEntry struct { + Slug string `yaml:"slug"` + Name string `yaml:"name"` + Provider string `yaml:"provider"` + VMID int `yaml:"vmid"` + HTTPURL string `yaml:"http_url"` + WSURL string `yaml:"ws_url"` + DefaultTier string `yaml:"default_tier"` + RequiresApproval bool `yaml:"requires_approval"` + BillingModel string `yaml:"billing_model"` + Description string `yaml:"description"` + UseCases []string `yaml:"use_cases"` + ManagementFeatures []string `yaml:"management_features"` +} + +type rpcProductsYAMLFile struct { + Products []rpcProductsYAMLEntry `yaml:"products"` +} + +var ( + rpcProductsOnce sync.Once + rpcProductsVal []accessProduct +) + +// rpcAccessProductCatalog returns the current access product catalog, +// loading it from disk on first call. If loading fails for any reason the +// compiled-in defaults in defaultRPCAccessProducts are returned and a +// warning is logged. Callers should treat the returned slice as read-only. +func rpcAccessProductCatalog() []accessProduct { + rpcProductsOnce.Do(func() { + loaded, path, err := loadRPCAccessProducts() + switch { + case err != nil: + log.Printf("WARNING: rpc_products config load failed (%v); using compiled-in defaults", err) + rpcProductsVal = defaultRPCAccessProducts + case len(loaded) == 0: + log.Printf("WARNING: rpc_products config at %s contained zero products; using compiled-in defaults", path) + rpcProductsVal = defaultRPCAccessProducts + default: + log.Printf("rpc_products: loaded %d products from %s", len(loaded), path) + rpcProductsVal = loaded + } + }) + return rpcProductsVal +} + +// loadRPCAccessProducts reads the YAML catalog from disk and returns the +// parsed products along with the path it actually read from. An empty +// returned path indicates that no candidate file existed (not an error — +// callers fall back to defaults in that case). +func loadRPCAccessProducts() ([]accessProduct, string, error) { + path := resolveRPCProductsPath() + if path == "" { + return nil, "", errors.New("no rpc_products.yaml found (set RPC_PRODUCTS_PATH or place config/rpc_products.yaml next to the binary)") + } + raw, err := os.ReadFile(path) // #nosec G304 -- path comes from env/repo-known locations + if err != nil { + return nil, path, fmt.Errorf("read %s: %w", path, err) + } + var decoded rpcProductsYAMLFile + if err := yaml.Unmarshal(raw, &decoded); err != nil { + return nil, path, fmt.Errorf("parse %s: %w", path, err) + } + products := make([]accessProduct, 0, len(decoded.Products)) + seen := make(map[string]struct{}, len(decoded.Products)) + for i, entry := range decoded.Products { + if strings.TrimSpace(entry.Slug) == "" { + return nil, path, fmt.Errorf("%s: product[%d] has empty slug", path, i) + } + if _, dup := seen[entry.Slug]; dup { + return nil, path, fmt.Errorf("%s: duplicate product slug %q", path, entry.Slug) + } + seen[entry.Slug] = struct{}{} + if strings.TrimSpace(entry.HTTPURL) == "" { + return nil, path, fmt.Errorf("%s: product %q is missing http_url", path, entry.Slug) + } + products = append(products, accessProduct{ + Slug: entry.Slug, + Name: entry.Name, + Provider: entry.Provider, + VMID: entry.VMID, + HTTPURL: strings.TrimSpace(entry.HTTPURL), + WSURL: strings.TrimSpace(entry.WSURL), + DefaultTier: entry.DefaultTier, + RequiresApproval: entry.RequiresApproval, + BillingModel: entry.BillingModel, + Description: strings.TrimSpace(entry.Description), + UseCases: entry.UseCases, + ManagementFeatures: entry.ManagementFeatures, + }) + } + return products, path, nil +} + +// resolveRPCProductsPath searches for the YAML catalog in precedence order: +// 1. $RPC_PRODUCTS_PATH (absolute or relative to cwd) +// 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml +// 3. /backend/config/rpc_products.yaml +// 4. /config/rpc_products.yaml +// +// Returns "" when no candidate exists. +func resolveRPCProductsPath() string { + if explicit := strings.TrimSpace(os.Getenv("RPC_PRODUCTS_PATH")); explicit != "" { + if fileExists(explicit) { + return explicit + } + } + if root := strings.TrimSpace(os.Getenv("EXPLORER_BACKEND_DIR")); root != "" { + candidate := filepath.Join(root, "config", "rpc_products.yaml") + if fileExists(candidate) { + return candidate + } + } + for _, candidate := range []string{ + filepath.Join("backend", "config", "rpc_products.yaml"), + filepath.Join("config", "rpc_products.yaml"), + } { + if fileExists(candidate) { + return candidate + } + } + return "" +} + +// defaultRPCAccessProducts is the emergency fallback used when the YAML +// catalog is absent or unreadable. Kept in sync with config/rpc_products.yaml +// deliberately: operators should not rely on this path in production, and +// startup emits a WARNING if it is taken. +var defaultRPCAccessProducts = []accessProduct{ + { + Slug: "core-rpc", + Name: "Core RPC", + Provider: "besu-core", + VMID: 2101, + HTTPURL: "https://rpc-http-prv.d-bis.org", + WSURL: "wss://rpc-ws-prv.d-bis.org", + DefaultTier: "enterprise", + RequiresApproval: true, + BillingModel: "contract", + Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.", + UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"}, + ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"}, + }, + { + Slug: "alltra-rpc", + Name: "Alltra RPC", + Provider: "alltra", + VMID: 2102, + HTTPURL: "http://192.168.11.212:8545", + WSURL: "ws://192.168.11.212:8546", + DefaultTier: "pro", + RequiresApproval: false, + BillingModel: "subscription", + Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.", + UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"}, + ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"}, + }, + { + Slug: "thirdweb-rpc", + Name: "Thirdweb RPC", + Provider: "thirdweb", + VMID: 2103, + HTTPURL: "http://192.168.11.217:8545", + WSURL: "ws://192.168.11.217:8546", + DefaultTier: "pro", + RequiresApproval: false, + BillingModel: "subscription", + Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.", + UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"}, + ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"}, + }, +} diff --git a/backend/api/rest/rpc_products_config_test.go b/backend/api/rest/rpc_products_config_test.go new file mode 100644 index 0000000..39c7d47 --- /dev/null +++ b/backend/api/rest/rpc_products_config_test.go @@ -0,0 +1,111 @@ +package rest + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadRPCAccessProductsFromRepoDefault(t *testing.T) { + // The repo ships config/rpc_products.yaml relative to backend/. When + // running `go test ./...` from the repo root, the loader's relative + // search path finds it there. Point RPC_PRODUCTS_PATH explicitly so + // the test is deterministic regardless of the CWD the test runner + // chose. + repoRoot, err := findBackendRoot() + if err != nil { + t.Fatalf("locate backend root: %v", err) + } + t.Setenv("RPC_PRODUCTS_PATH", filepath.Join(repoRoot, "config", "rpc_products.yaml")) + + products, path, err := loadRPCAccessProducts() + if err != nil { + t.Fatalf("loadRPCAccessProducts: %v", err) + } + if path == "" { + t.Fatalf("loadRPCAccessProducts returned empty path") + } + if len(products) < 3 { + t.Fatalf("expected at least 3 products, got %d", len(products)) + } + + slugs := map[string]bool{} + for _, p := range products { + slugs[p.Slug] = true + if p.HTTPURL == "" { + t.Errorf("product %q has empty http_url", p.Slug) + } + } + for _, required := range []string{"core-rpc", "alltra-rpc", "thirdweb-rpc"} { + if !slugs[required] { + t.Errorf("expected product slug %q in catalog", required) + } + } +} + +func TestLoadRPCAccessProductsRejectsDuplicateSlug(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rpc_products.yaml") + yaml := `products: + - slug: a + http_url: https://a.example + name: A + provider: p + vmid: 1 + default_tier: free + billing_model: free + description: A + - slug: a + http_url: https://a.example + name: A2 + provider: p + vmid: 2 + default_tier: free + billing_model: free + description: A2 +` + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + t.Setenv("RPC_PRODUCTS_PATH", path) + + if _, _, err := loadRPCAccessProducts(); err == nil { + t.Fatal("expected duplicate-slug error, got nil") + } +} + +func TestLoadRPCAccessProductsRejectsMissingHTTPURL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rpc_products.yaml") + if err := os.WriteFile(path, []byte("products:\n - slug: x\n name: X\n"), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + t.Setenv("RPC_PRODUCTS_PATH", path) + + if _, _, err := loadRPCAccessProducts(); err == nil { + t.Fatal("expected missing-http_url error, got nil") + } +} + +// findBackendRoot walks up from the test working directory until it finds +// a directory containing a go.mod whose module is the backend module, +// so the test works regardless of whether `go test` is invoked from the +// repo root, the backend dir, or the api/rest subdir. +func findBackendRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + for { + goMod := filepath.Join(cwd, "go.mod") + if _, err := os.Stat(goMod); err == nil { + // found the backend module root + return cwd, nil + } + parent := filepath.Dir(cwd) + if parent == cwd { + return "", os.ErrNotExist + } + cwd = parent + } +} diff --git a/backend/config/rpc_products.yaml b/backend/config/rpc_products.yaml new file mode 100644 index 0000000..e56ffa5 --- /dev/null +++ b/backend/config/rpc_products.yaml @@ -0,0 +1,97 @@ +# Chain 138 RPC access product catalog. +# +# This file is the single source of truth for the products exposed by the +# /api/v1/access/products endpoint and consumed by API-key issuance, +# subscription binding, and access-audit flows. Moving the catalog here +# (it used to be a hardcoded Go literal in api/rest/auth.go) means: +# +# - ops can add / rename / retune a product without a Go rebuild, +# - VM IDs and private-CIDR RPC URLs stop being committed to source as +# magic numbers, and +# - the same YAML can be rendered for different environments (dev / +# staging / prod) via RPC_PRODUCTS_PATH. +# +# Path resolution at startup: +# 1. $RPC_PRODUCTS_PATH if set (absolute or relative to the working dir), +# 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml if that env var is set, +# 3. the first of /backend/config/rpc_products.yaml or +# /config/rpc_products.yaml that exists, +# 4. the compiled-in fallback slice (legacy behaviour; logs a warning). +# +# Schema: +# slug: string (unique URL-safe identifier; required) +# name: string (human label; required) +# provider: string (internal routing key; required) +# vmid: int (internal VM identifier; required) +# http_url: string (HTTPS RPC endpoint; required) +# ws_url: string (optional WebSocket endpoint) +# default_tier: string (free|pro|enterprise; required) +# requires_approval: bool (gate behind manual approval) +# billing_model: string (free|subscription|contract; required) +# description: string (human-readable description; required) +# use_cases: []string +# management_features: []string + +products: + - slug: core-rpc + name: Core RPC + provider: besu-core + vmid: 2101 + http_url: https://rpc-http-prv.d-bis.org + ws_url: wss://rpc-ws-prv.d-bis.org + default_tier: enterprise + requires_approval: true + billing_model: contract + description: >- + Private Chain 138 Core RPC for operator-grade administration and + sensitive workloads. + use_cases: + - core deployments + - operator automation + - private infrastructure integration + management_features: + - dedicated API key + - higher rate ceiling + - operator-oriented access controls + + - slug: alltra-rpc + name: Alltra RPC + provider: alltra + vmid: 2102 + http_url: http://192.168.11.212:8545 + ws_url: ws://192.168.11.212:8546 + default_tier: pro + requires_approval: false + billing_model: subscription + description: >- + Dedicated Alltra-managed RPC lane for partner traffic, subscription + access, and API-key-gated usage. + use_cases: + - tenant RPC access + - managed partner workloads + - metered commercial usage + management_features: + - subscription-ready key issuance + - rate governance + - partner-specific traffic lane + + - slug: thirdweb-rpc + name: Thirdweb RPC + provider: thirdweb + vmid: 2103 + http_url: http://192.168.11.217:8545 + ws_url: ws://192.168.11.217:8546 + default_tier: pro + requires_approval: false + billing_model: subscription + description: >- + Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access + and API-token paywalling. + use_cases: + - thirdweb integrations + - commercial API access + - managed dApp traffic + management_features: + - API token issuance + - usage tiering + - future paywall/subscription hooks diff --git a/backend/go.mod b/backend/go.mod index 8fe44be..709bdac 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,7 @@ require ( github.com/redis/go-redis/v9 v9.17.2 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.36.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -51,6 +52,5 @@ require ( golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.33.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/tmplfunc v0.0.3 // indirect )