From d61f808520a08fe059825e0bf3d0b5d4636cfc69 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 13 Feb 2026 16:46:39 -0600 Subject: [PATCH 1/7] Run the CDN for chaintracks too Signed-off-by: Deggen --- cmd/arcade/main.go | 29 ++++++++++++++++++++++++++--- go.mod | 2 +- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index 7a94b2c..202db9f 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -28,6 +28,7 @@ import ( "os" "os/signal" "syscall" + "time" chaintracksRoutes "github.com/bsv-blockchain/go-chaintracks/routes/fiber" "github.com/gofiber/fiber/v2" @@ -115,7 +116,7 @@ func run(ctx context.Context, cfg *config.Config, log *slog.Logger) error { authToken = cfg.Auth.Token log.Info("API authentication enabled") } - app := setupServer(arcadeRoutes, chaintracksRts, dashboard, authToken) + app := setupServer(arcadeRoutes, chaintracksRts, dashboard, authToken, cfg.Chaintracks.StoragePath) errCh := make(chan error, 1) go func() { @@ -156,7 +157,7 @@ func waitForShutdown(ctx context.Context, cfg *config.Config, log *slog.Logger, return nil } -func setupServer(arcadeRoutes *fiberRoutes.Routes, chaintracksRts *chaintracksRoutes.Routes, dashboard *Dashboard, authToken string) *fiber.App { +func setupServer(arcadeRoutes *fiberRoutes.Routes, chaintracksRts *chaintracksRoutes.Routes, dashboard *Dashboard, authToken string, chaintracksStoragePath string) *fiber.App { app := fiber.New(fiber.Config{ DisableStartupMessage: true, }) @@ -183,6 +184,15 @@ func setupServer(arcadeRoutes *fiberRoutes.Routes, chaintracksRts *chaintracksRo chaintracksGroup := app.Group("/chaintracks") chaintracksRts.Register(chaintracksGroup.Group("/v2")) chaintracksRts.RegisterLegacy(chaintracksGroup.Group("/v1")) + + // CDN static file serving for bulk header downloads + // Serves files like mainNetBlockHeaders.json and mainNet_X.headers + chaintracksGroup.Static("/", chaintracksStoragePath, fiber.Static{ + Compress: true, + ByteRange: true, // Support Range requests for partial downloads + Browse: false, + CacheDuration: 1 * time.Hour, + }) } // Health check (standalone arcade server only) @@ -194,7 +204,20 @@ func setupServer(arcadeRoutes *fiberRoutes.Routes, chaintracksRts *chaintracksRo // API docs (Scalar UI) app.Get("/docs/openapi.json", func(c *fiber.Ctx) error { - return c.Type("json").SendString(docs.SwaggerInfo.ReadDoc()) + arcadeSpec := docs.SwaggerInfo.ReadDoc() + + // Merge with chaintracks spec if chaintracks routes are enabled + if chaintracksRts != nil { + mergedSpec, err := mergeOpenAPISpecs(arcadeSpec, "/chaintracks") + if err != nil { + // Log error but fallback to arcade-only spec + slog.Error("Failed to merge OpenAPI specs", slog.String("error", err.Error())) + return c.Type("json").SendString(arcadeSpec) + } + return c.Type("json").SendString(mergedSpec) + } + + return c.Type("json").SendString(arcadeSpec) }) app.Get("/docs", func(c *fiber.Ctx) error { return c.Type("html").SendString(scalarHTML) diff --git a/go.mod b/go.mod index 3b54769..9708431 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/swaggo/swag v1.16.6 github.com/valyala/fasthttp v1.69.0 + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.45.0 ) @@ -264,7 +265,6 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.35.1 // indirect k8s.io/apimachinery v0.35.1 // indirect k8s.io/client-go v0.35.1 // indirect From 51411d201536a139aab874734bfe0f8098d4fd47 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 13 Feb 2026 16:46:52 -0600 Subject: [PATCH 2/7] =?UTF-8?q?the=20container=20should=20build=20for=20th?= =?UTF-8?q?e=20platform=20it=E2=80=99s=20on.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Deggen --- Dockerfile | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index a265598..80753a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Build stage -FROM golang:1.25.4-alpine@sha256:96f36e77302b6982abdd9849dff329feef03b0f2520c24dc2352fc4b33ed776d AS builder +FROM golang:1.25.4-alpine AS builder # Install build dependencies # build-base: includes gcc, g++, make and other build tools for CGO @@ -29,18 +29,14 @@ ARG BUILD_DATE=unknown # -ldflags explanation: # -s: omit symbol table # -w: omit DWARF debug info -# -X: set version variables RUN CGO_ENABLED=1 GOOS=linux go build \ - -ldflags="-s -w \ - -X main.version=${VERSION} \ - -X main.commit=${COMMIT} \ - -X main.buildDate=${BUILD_DATE}" \ + -ldflags="-s -w" \ -trimpath \ -o arcade \ ./cmd/arcade # Runtime stage - using Alpine for small size and C library compatibility (SQLite) -FROM alpine:3.21@sha256:22e0ec13c0db6b3e1ba3280e831fc50ba7bffe58e81f31670a64b1afede247bc +FROM alpine:3.21 # Install runtime dependencies # ca-certificates: for HTTPS connections to Teranode From fbcfa65c9dd0eb1def238ef5630a65a778ab12b9 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 13 Feb 2026 16:47:34 -0600 Subject: [PATCH 3/7] update details Signed-off-by: Deggen --- DEPLOY.md | 37 ++++++++++++++++++++++--------------- docker-compose.yaml | 18 ++++++++++++++---- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index ff0d540..588fad2 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -33,34 +33,41 @@ The container exposes `GET /health` on port 3011. Use this for readiness/livenes ## Docker Compose -1. **Create a config file:** +The `docker-compose.yaml` includes production mainnet configuration by default. All required environment variables are pre-configured. -```bash -cp config.example.yaml config.yaml -``` - -Edit `config.yaml` and set your `teranode.broadcast_urls`. - -2. **Start Arcade:** +### Quick Start ```bash +# Start Arcade (uses mainnet by default) docker compose up -d -``` -3. **Verify it's running:** +# View logs +docker compose logs -f arcade -```bash +# Check health curl http://localhost:3011/health -docker compose logs -f arcade + +# Stop +docker compose down ``` -4. **Stop:** +Data is persisted in the `arcade-data` Docker volume and survives restarts. + +### Using Different Networks +For testnet: ```bash -docker compose down +ARCADE_NETWORK=test \ +ARCADE_TERANODE_BROADCAST_URLS="https://teranode-eks-testnet-eu-1-propagation.bsvb.tech,https://teranode-eks-testnet-us-1-propagation.bsvb.tech" \ +docker compose up -d ``` -Data is persisted in the `arcade-data` Docker volume and survives restarts. +For teratestnet: +```bash +ARCADE_NETWORK=teratestnet \ +ARCADE_TERANODE_BROADCAST_URLS="https://teranode-eks-ttn-eu-1-propagation.bsvb.tech,https://teranode-eks-ttn-us-1-propagation.bsvb.tech" \ +docker compose up -d +``` ### Environment-only configuration (no config file) diff --git a/docker-compose.yaml b/docker-compose.yaml index 83e7ef9..7ab9e5b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,16 +13,26 @@ services: ports: - "3011:3011" volumes: - # Mount config file - - ./config.yaml:/app/config.yaml:ro # Persistent data directory - arcade-data:/data environment: - # Override config via environment variables + # Network configuration ARCADE_NETWORK: ${ARCADE_NETWORK:-main} - ARCADE_LOG_LEVEL: ${ARCADE_LOG_LEVEL:-info} + + # Storage paths ARCADE_STORAGE_PATH: /data + ARCADE_DATABASE_SQLITE_PATH: /data/arcade.db + ARCADE_CHAINTRACKS_STORAGE_PATH: /data/chaintracks + + # Teranode endpoints (production mainnet by default) + ARCADE_TERANODE_BROADCAST_URLS: ${ARCADE_TERANODE_BROADCAST_URLS:-https://teranode-eks-mainnet-eu-1-propagation.bsvb.tech,https://teranode-eks-mainnet-us-1-propagation.bsvb.tech} + + # Server configuration ARCADE_SERVER_ADDRESS: :3011 + ARCADE_LOG_LEVEL: ${ARCADE_LOG_LEVEL:-info} + + # Optional: Enable chaintracks HTTP API (default: true) + ARCADE_CHAINTRACKS_SERVER_ENABLED: ${ARCADE_CHAINTRACKS_SERVER_ENABLED:-true} restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3011/health"] From e2c0b9fddc002b219993379d579711aba7303844 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 13 Feb 2026 17:00:32 -0600 Subject: [PATCH 4/7] secrets for propagation endpoints required Signed-off-by: Deggen --- DEPLOY.md | 15 ++++++++++++++- docker-compose.yaml | 4 +++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 588fad2..85d5646 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -17,7 +17,10 @@ Arcade is configured via environment variables or a `config.yaml` file. Environm | `ARCADE_STORAGE_PATH` | Root directory for persistent data | `/data` | | `ARCADE_DATABASE_SQLITE_PATH` | Path to SQLite database file | `/data/arcade.db` | | `ARCADE_CHAINTRACKS_STORAGE_PATH` | Path for chain header storage | `/data/chaintracks` | +| `ARCADE_CHAINTRACKS_BOOTSTRAP_URL` | URL to headers.bin for initial header sync | _(none)_ | | `ARCADE_TERANODE_BROADCAST_URLS` | Comma-separated Teranode propagation URLs | _(none)_ | +| `ARCADE_TERANODE_DATAHUB_URLS` | Comma-separated Teranode DataHub URLs (fallback) | _(none)_ | +| `ARCADE_TERANODE_AUTH_TOKEN` | **Bearer token for Teranode authentication (required)** | _(none)_ | | `ARCADE_SERVER_ADDRESS` | Listen address | `:3011` | | `ARCADE_LOG_LEVEL` | Log level: `debug`, `info`, `warn`, `error` | `info` | | `ARCADE_AUTH_ENABLED` | Enable authentication | `false` | @@ -33,7 +36,15 @@ The container exposes `GET /health` on port 3011. Use this for readiness/livenes ## Docker Compose -The `docker-compose.yaml` includes production mainnet configuration by default. All required environment variables are pre-configured. +The `docker-compose.yaml` includes production mainnet configuration by default. + +### Authentication Required + +⚠️ **You must provide a Teranode authentication token** to submit transactions: + +```bash +export ARCADE_TERANODE_AUTH_TOKEN="your-token-here" +``` ### Quick Start @@ -59,6 +70,7 @@ For testnet: ```bash ARCADE_NETWORK=test \ ARCADE_TERANODE_BROADCAST_URLS="https://teranode-eks-testnet-eu-1-propagation.bsvb.tech,https://teranode-eks-testnet-us-1-propagation.bsvb.tech" \ +ARCADE_TERANODE_DATAHUB_URLS="https://teranode-eks-testnet-eu-1.bsvb.tech/api/v1" \ docker compose up -d ``` @@ -66,6 +78,7 @@ For teratestnet: ```bash ARCADE_NETWORK=teratestnet \ ARCADE_TERANODE_BROADCAST_URLS="https://teranode-eks-ttn-eu-1-propagation.bsvb.tech,https://teranode-eks-ttn-us-1-propagation.bsvb.tech" \ +ARCADE_TERANODE_DATAHUB_URLS="https://teranode-eks-ttn-eu-1.bsvb.tech/api/v1" \ docker compose up -d ``` diff --git a/docker-compose.yaml b/docker-compose.yaml index 7ab9e5b..fb4cb5c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,10 +26,12 @@ services: # Teranode endpoints (production mainnet by default) ARCADE_TERANODE_BROADCAST_URLS: ${ARCADE_TERANODE_BROADCAST_URLS:-https://teranode-eks-mainnet-eu-1-propagation.bsvb.tech,https://teranode-eks-mainnet-us-1-propagation.bsvb.tech} + ARCADE_TERANODE_DATAHUB_URLS: ${ARCADE_TERANODE_DATAHUB_URLS:-https://teranode-eks-mainnet-eu-1.bsvb.tech/api/v1} + ARCADE_TERANODE_AUTH_TOKEN: ${ARCADE_TERANODE_AUTH_TOKEN:-} # Required for authentication # Server configuration ARCADE_SERVER_ADDRESS: :3011 - ARCADE_LOG_LEVEL: ${ARCADE_LOG_LEVEL:-info} + ARCADE_LOG_LEVEL: ${ARCADE_LOG_LEVEL:-debug} # Optional: Enable chaintracks HTTP API (default: true) ARCADE_CHAINTRACKS_SERVER_ENABLED: ${ARCADE_CHAINTRACKS_SERVER_ENABLED:-true} From f12186785046a41431a9fb9b8684f7794e72034a Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 24 Feb 2026 09:55:20 -0600 Subject: [PATCH 5/7] fix: track openapi merge files and exclude CVE-2026-26958 - Fix .gitignore: anchor 'arcade' to '/arcade' (root binary only) so cmd/arcade/openapi.go and cmd/arcade/static/ are no longer ignored - Add cmd/arcade/openapi.go, openapi_test.go, static/chaintracks-openapi.yaml which implement runtime OpenAPI spec merging for the Scalar UI docs - Exclude CVE-2026-26958 (filippo.io/edwards25519 CWE-665) from Nancy scan; transitive dep via teranode@v0.13.2 used only through go-sql-driver/mysql (not directly affected path); cannot pin v1.2.0 without breaking mod-tidy Co-Authored-By: Claude Sonnet 4.6 --- .github/env/90-project.env | 11 +- .gitignore | 2 +- cmd/arcade/openapi.go | 209 +++++ cmd/arcade/openapi_test.go | 82 ++ cmd/arcade/static/chaintracks-openapi.yaml | 870 +++++++++++++++++++++ 5 files changed, 1171 insertions(+), 3 deletions(-) create mode 100644 cmd/arcade/openapi.go create mode 100644 cmd/arcade/openapi_test.go create mode 100644 cmd/arcade/static/chaintracks-openapi.yaml diff --git a/.github/env/90-project.env b/.github/env/90-project.env index a617050..d28c829 100644 --- a/.github/env/90-project.env +++ b/.github/env/90-project.env @@ -43,10 +43,10 @@ GO_COVERAGE_EXCLUDE_PATHS=.github/,.mage-cache/,.vscode/,bin/,example/,examples/ # ================================================================================================ # Nancy CVE Exclusions -NANCY_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668,CVE-2023-26248,CVE-2026-24051,CVE-2026-26014 +NANCY_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668,CVE-2023-26248,CVE-2026-24051,CVE-2026-26014,CVE-2026-26958 # Govulncheck/Magex CVE Exclusions -MAGE_X_CVE_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668,CVE-2023-26248,CVE-2026-24051,CVE-2026-26014 +MAGE_X_CVE_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668,CVE-2023-26248,CVE-2026-24051,CVE-2026-26014,CVE-2026-26958 # CVE-2026-26014 for pion/dtls (EXCLUDED: Invalid/non-existent CVE) # @@ -58,6 +58,13 @@ MAGE_X_CVE_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668, # Rationale: No actual vulnerability exists. Nancy likely reporting stale/incorrect data. # Resolution: Excluded as false positive. Already using latest pion/dtls versions. +# CVE-2026-26958 for filippo.io/edwards25519@v1.1.0 (CWE-665 Improper Initialization) +# Transitive dependency via github.com/bsv-blockchain/teranode@v0.13.2. +# Advisory notes that uses "only through github.com/go-sql-driver/mysql are not affected", +# which is our path (teranode uses it for MySQL auth). Cannot upgrade via go mod tidy since +# no package in this module directly imports edwards25519; pin would be removed. Will resolve +# when teranode upgrades to filippo.io/edwards25519 v1.2.0. + # CVE-2026-24051 for go.opentelemetry.io/otel/sdk@v1.39.0 (macOS PATH hijacking - low risk) # CVE-2025-64702 for quic-go@v0.55.0 # GO-2024-3218: Content Censorship in IPFS via Kademlia DHT abuse in github.com/libp2p/go-libp2p-kad-dht diff --git a/.gitignore b/.gitignore index 12fec2d..916609d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,5 @@ peer_cache.json *.log -arcade +/arcade config.yaml diff --git a/cmd/arcade/openapi.go b/cmd/arcade/openapi.go new file mode 100644 index 0000000..9f9b863 --- /dev/null +++ b/cmd/arcade/openapi.go @@ -0,0 +1,209 @@ +package main + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed static/chaintracks-openapi.yaml +var chaintracksOpenAPIYAML []byte + +// OpenAPI Spec Merging +// +// This file implements runtime merging of Arcade's OpenAPI spec with the +// Chaintracks OpenAPI spec. When the chaintracks HTTP API is enabled, both +// specs are combined into a single document served at /docs/openapi.json. +// +// The merge process: +// 1. Loads Arcade's spec from swaggo-generated docs +// 2. Loads Chaintracks spec from embedded YAML file +// 3. Prefixes all chaintracks paths with "/chaintracks" +// 4. Prefixes chaintracks tags with "chaintracks-" to avoid conflicts +// 5. Prefixes chaintracks schema names with "Chaintracks" prefix +// 6. Updates all schema $ref references to use the new prefixed names +// +// This ensures both APIs are documented in a single Scalar UI interface. + +// mergeOpenAPISpecs merges the arcade and chaintracks OpenAPI specifications. +// It prefixes all chaintracks paths with the given pathPrefix. +func mergeOpenAPISpecs(arcadeJSON string, pathPrefix string) (string, error) { + // Parse arcade spec (JSON from swagger) + var arcadeSpec map[string]interface{} + if err := json.Unmarshal([]byte(arcadeJSON), &arcadeSpec); err != nil { + return "", fmt.Errorf("failed to parse arcade spec: %w", err) + } + + // Parse chaintracks spec (YAML) + var chaintracksSpec map[string]interface{} + if err := yaml.Unmarshal(chaintracksOpenAPIYAML, &chaintracksSpec); err != nil { + return "", fmt.Errorf("failed to parse chaintracks spec: %w", err) + } + + // Get or create paths map in arcade spec + arcadePaths, ok := arcadeSpec["paths"].(map[string]interface{}) + if !ok { + arcadePaths = make(map[string]interface{}) + arcadeSpec["paths"] = arcadePaths + } + + // Merge chaintracks paths with prefix + if chaintracksPaths, ok := chaintracksSpec["paths"].(map[string]interface{}); ok { + for path, pathItem := range chaintracksPaths { + // Skip CDN-only health endpoint (arcade has its own /health) + if isCDNOnlyPath(path) { + continue + } + + // Determine if this is a v1 legacy path by checking its tags + isLegacyPath := false + if pathItemMap, ok := pathItem.(map[string]interface{}); ok { + for _, operation := range pathItemMap { + if opMap, ok := operation.(map[string]interface{}); ok { + if tags, ok := opMap["tags"].([]interface{}); ok { + for _, tag := range tags { + if tagStr, ok := tag.(string); ok && tagStr == "Legacy" { + isLegacyPath = true + break + } + } + } + } + if isLegacyPath { + break + } + } + } + + // Add appropriate prefix based on path type + var prefixedPath string + if isLegacyPath { + // v1 legacy paths need /v1 added + prefixedPath = pathPrefix + "/v1" + path + } else if strings.HasPrefix(path, "/v2/") { + // v2 paths already have /v2 prefix + prefixedPath = pathPrefix + path + } else { + // Other paths (shouldn't happen after CDN filtering) + prefixedPath = pathPrefix + path + } + + arcadePaths[prefixedPath] = pathItem + } + } + + // Merge tags + arcadeTags, _ := arcadeSpec["tags"].([]interface{}) + if chaintracksTags, ok := chaintracksSpec["tags"].([]interface{}); ok { + // Add chaintracks tags with a prefix to avoid confusion + for _, tag := range chaintracksTags { + if tagMap, ok := tag.(map[string]interface{}); ok { + // Prefix tag name with "chaintracks-" + if name, ok := tagMap["name"].(string); ok { + // Rename "Legacy" to "v1" for consistency + if name == "Legacy" { + tagMap["name"] = "chaintracks-v1" + } else { + tagMap["name"] = "chaintracks-" + name + } + } + arcadeTags = append(arcadeTags, tagMap) + } + } + arcadeSpec["tags"] = arcadeTags + } + + // Merge components/schemas if they exist + arcadeComponents, ok := arcadeSpec["components"].(map[string]interface{}) + if !ok { + arcadeComponents = make(map[string]interface{}) + arcadeSpec["components"] = arcadeComponents + } + + if chaintracksComponents, ok := chaintracksSpec["components"].(map[string]interface{}); ok { + if chaintracksSchemas, ok := chaintracksComponents["schemas"].(map[string]interface{}); ok { + arcadeSchemas, ok := arcadeComponents["schemas"].(map[string]interface{}) + if !ok { + arcadeSchemas = make(map[string]interface{}) + arcadeComponents["schemas"] = arcadeSchemas + } + + // Prefix chaintracks schema names to avoid conflicts + for schemaName, schema := range chaintracksSchemas { + arcadeSchemas["Chaintracks"+schemaName] = schema + } + } + } + + // Update all chaintracks path references to use prefixed tags + for path, pathItem := range arcadePaths { + if pathItemMap, ok := pathItem.(map[string]interface{}); ok { + for method, operation := range pathItemMap { + if operationMap, ok := operation.(map[string]interface{}); ok { + if tags, ok := operationMap["tags"].([]interface{}); ok { + for i, tag := range tags { + if tagStr, ok := tag.(string); ok { + // If it's a chaintracks tag, prefix it (rename Legacy to v1) + if tagStr == "v2" || tagStr == "CDN" { + tags[i] = "chaintracks-" + tagStr + } else if tagStr == "Legacy" { + tags[i] = "chaintracks-v1" + } + } + } + } + + // Update schema references for chaintracks endpoints + if pathPrefix != "" && len(path) >= len(pathPrefix) && path[:len(pathPrefix)] == pathPrefix { + updateSchemaRefs(operationMap, "Chaintracks") + } + + pathItemMap[method] = operationMap + } + } + } + } + + // Marshal back to JSON + mergedJSON, err := json.MarshalIndent(arcadeSpec, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal merged spec: %w", err) + } + + return string(mergedJSON), nil +} + +// isCDNOnlyPath returns true if the path is a CDN-only endpoint that should be excluded. +// We exclude the CDN health check since arcade has its own /health endpoint. +func isCDNOnlyPath(path string) bool { + // Exclude only the CDN-specific health check + // (We DO serve the CDN bootstrap files via static file serving) + return path == "/health" // CDN health check - arcade has its own /health +} + +// updateSchemaRefs recursively updates schema references by adding a prefix +func updateSchemaRefs(obj interface{}, prefix string) { + switch v := obj.(type) { + case map[string]interface{}: + for key, val := range v { + if key == "$ref" { + if refStr, ok := val.(string); ok { + // Update component schema references + if len(refStr) > 21 && refStr[:21] == "#/components/schemas/" { + schemaName := refStr[21:] + v[key] = "#/components/schemas/" + prefix + schemaName + } + } + } else { + updateSchemaRefs(val, prefix) + } + } + case []interface{}: + for _, item := range v { + updateSchemaRefs(item, prefix) + } + } +} diff --git a/cmd/arcade/openapi_test.go b/cmd/arcade/openapi_test.go new file mode 100644 index 0000000..29284e6 --- /dev/null +++ b/cmd/arcade/openapi_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestMergeOpenAPISpecs(t *testing.T) { + // Sample arcade spec (minimal) + arcadeSpec := `{ + "swagger": "2.0", + "info": { + "title": "Arcade API", + "version": "0.1.0" + }, + "paths": { + "/tx": { + "post": { + "tags": ["arcade"], + "summary": "Submit transaction" + } + } + }, + "tags": [ + {"name": "arcade", "description": "Arcade endpoints"} + ] + }` + + // Merge with chaintracks + merged, err := mergeOpenAPISpecs(arcadeSpec, "/chaintracks") + if err != nil { + t.Fatalf("Failed to merge specs: %v", err) + } + + // Parse merged spec + var spec map[string]interface{} + if err := json.Unmarshal([]byte(merged), &spec); err != nil { + t.Fatalf("Failed to parse merged spec: %v", err) + } + + // Verify arcade paths still exist + paths := spec["paths"].(map[string]interface{}) + if _, ok := paths["/tx"]; !ok { + t.Error("Arcade /tx path not found in merged spec") + } + + // Verify chaintracks paths were added with prefix + foundChaintracksPaths := false + for path := range paths { + if len(path) >= 12 && path[:12] == "/chaintracks" { + foundChaintracksPaths = true + break + } + } + if !foundChaintracksPaths { + t.Error("No chaintracks paths found with /chaintracks prefix") + } + + // Verify tags were merged + tags := spec["tags"].([]interface{}) + if len(tags) < 2 { + t.Error("Expected at least 2 tags after merge") + } + + // Verify chaintracks tags were prefixed + foundPrefixedTag := false + for _, tag := range tags { + if tagMap, ok := tag.(map[string]interface{}); ok { + if name, ok := tagMap["name"].(string); ok { + if len(name) >= 12 && name[:12] == "chaintracks-" { + foundPrefixedTag = true + break + } + } + } + } + if !foundPrefixedTag { + t.Error("No chaintracks- prefixed tags found") + } + + t.Logf("Successfully merged specs with %d total paths", len(paths)) +} diff --git a/cmd/arcade/static/chaintracks-openapi.yaml b/cmd/arcade/static/chaintracks-openapi.yaml new file mode 100644 index 0000000..11dcf0e --- /dev/null +++ b/cmd/arcade/static/chaintracks-openapi.yaml @@ -0,0 +1,870 @@ +openapi: 3.0.3 +info: + title: Chaintracks Server API + description: | + REST API for querying Bitcoin SV blockchain headers. + + ## Services + - **API Server** (port 3011): Real-time header queries and streaming + - **CDN Server** (port 3012): Static header files for bootstrap (optional) + + ## CDN Bootstrap Format + The CDN serves static header files compatible with the TypeScript chaintracks-server CDN format: + - `{network}NetBlockHeaders.json` - Metadata with file list + - `{network}Net_X.headers` - Binary header files (100k headers × 80 bytes = ~8MB each) + + Where `{network}` is `main` or `test`. + version: 2.0.0 +servers: + - url: / + description: API Server (default port 3011) +tags: + - name: v2 + description: Modern v2 API endpoints + - name: Legacy + description: Legacy v1 endpoints for backwards compatibility + - name: CDN + description: CDN static file server endpoints (port 3012, when enabled) +paths: + /v2/network: + get: + summary: Get blockchain network + description: Returns the network name (main or test) + tags: + - v2 + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + value: + type: string + + /v2/tip: + get: + summary: Get chain tip header + description: Returns the full header of the current chain tip + tags: + - v2 + responses: + '200': + description: Successful response + headers: + Cache-Control: + schema: + type: string + description: Cache control header (no-cache) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '404': + description: Chain tip not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/tip/stream: + get: + summary: Subscribe to tip updates (SSE) + description: Server-Sent Events stream for real-time chain tip updates + tags: + - v2 + responses: + '200': + description: SSE stream of tip updates + content: + text/event-stream: + schema: + type: string + description: JSON-encoded BlockHeader objects + + /v2/tip.bin: + get: + summary: Get chain tip as binary + description: Returns the chain tip as 80-byte binary data + tags: + - v2 + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (no-cache) + X-Block-Height: + schema: + type: integer + description: Block height + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Chain tip not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/header/height/{height}: + get: + summary: Get header by height + description: Returns a block header for a specific height + tags: + - v2 + parameters: + - name: height + in: path + required: true + schema: + type: integer + format: uint32 + description: Block height + responses: + '200': + description: Successful response + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/header/height/{height}.bin: + get: + summary: Get header by height as binary + description: Returns a block header as 80-byte binary data + tags: + - v2 + parameters: + - name: height + in: path + required: true + schema: + type: integer + format: uint32 + description: Block height + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + X-Block-Height: + schema: + type: integer + description: Block height + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/header/hash/{hash}: + get: + summary: Get header by hash + description: Returns a block header for a specific block hash + tags: + - v2 + parameters: + - name: hash + in: path + required: true + schema: + type: string + description: Block hash (hex string) + responses: + '200': + description: Successful response + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/header/hash/{hash}.bin: + get: + summary: Get header by hash as binary + description: Returns a block header as 80-byte binary data + tags: + - v2 + parameters: + - name: hash + in: path + required: true + schema: + type: string + description: Block hash (hex string) + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + X-Block-Height: + schema: + type: integer + description: Block height + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/headers: + get: + summary: Get multiple headers + description: Returns multiple block headers as binary (80 bytes each) + tags: + - v2 + parameters: + - name: height + in: query + required: true + schema: + type: integer + format: uint32 + description: Starting block height + - name: count + in: query + required: true + schema: + type: integer + format: uint32 + description: Number of headers to retrieve + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + content: + application/octet-stream: + schema: + type: string + format: binary + description: Concatenated block headers (80 bytes per header) + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v2/headers.bin: + get: + summary: Get multiple headers as binary + description: Returns multiple block headers as binary with metadata headers + tags: + - v2 + parameters: + - name: height + in: query + required: true + schema: + type: integer + format: uint32 + description: Starting block height + - name: count + in: query + required: true + schema: + type: integer + format: uint32 + description: Number of headers to retrieve + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: Cache control header (varies based on height) + X-Start-Height: + schema: + type: integer + description: Starting block height + X-Header-Count: + schema: + type: integer + description: Number of headers returned + content: + application/octet-stream: + schema: + type: string + format: binary + description: Concatenated block headers (80 bytes per header) + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # Legacy v1 endpoints (RPC-style API for backwards compatibility) + /getChain: + get: + summary: Get blockchain network (legacy) + description: Legacy endpoint - Returns the network name + tags: + - Legacy + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/LegacySuccessResponse' + + /getPresentHeight: + get: + summary: Get current height (legacy) + description: Legacy endpoint - Returns the current blockchain height + tags: + - Legacy + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/LegacySuccessResponse' + + /findChainTipHashHex: + get: + summary: Get chain tip hash (legacy) + description: Legacy endpoint - Returns the chain tip hash + tags: + - Legacy + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/LegacySuccessResponse' + '404': + description: Chain tip not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /findChainTipHeaderHex: + get: + summary: Get chain tip header (legacy) + description: Legacy endpoint - Returns the chain tip header + tags: + - Legacy + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/LegacySuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '404': + description: Chain tip not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /findHeaderHexForHeight: + get: + summary: Get header by height (legacy) + description: Legacy endpoint - Returns a header by height + tags: + - Legacy + parameters: + - name: height + in: query + required: true + schema: + type: integer + format: uint32 + description: Block height + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/LegacySuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /findHeaderHexForBlockHash: + get: + summary: Get header by hash (legacy) + description: Legacy endpoint - Returns a header by block hash + tags: + - Legacy + parameters: + - name: hash + in: query + required: true + schema: + type: string + description: Block hash (hex string) + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/LegacySuccessResponse' + - type: object + properties: + value: + $ref: '#/components/schemas/BlockHeader' + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Header not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /getHeaders: + get: + summary: Get multiple headers (legacy) + description: Legacy endpoint - Returns multiple headers as hex string + tags: + - Legacy + parameters: + - name: height + in: query + required: true + schema: + type: integer + format: uint32 + description: Starting block height + - name: count + in: query + required: true + schema: + type: integer + format: uint32 + description: Number of headers to retrieve + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/LegacySuccessResponse' + - type: object + properties: + value: + type: string + description: Concatenated headers as hex string + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # CDN Server endpoints (port 3012, when enabled) + # Note: These endpoints are served by a separate CDN server + /{network}NetBlockHeaders.json: + get: + summary: CDN metadata file + description: | + Returns metadata JSON describing available header files. + Served from CDN server (default port 3012). + + Example response: + ```json + { + "rootFolder": "", + "jsonFilename": "mainNetBlockHeaders.json", + "headersPerFile": 100000, + "files": [ + { + "chain": "main", + "count": 100000, + "fileHash": "abc123...", + "fileName": "mainNet_0.headers", + "firstHeight": 0, + "lastChainWork": "0x...", + "lastHash": "000000...", + "prevChainWork": "0x0", + "prevHash": "000000...", + "sourceUrl": "https://..." + } + ] + } + ``` + tags: + - CDN + parameters: + - name: network + in: path + required: true + schema: + type: string + enum: [main, test] + description: Network name (main or test) + responses: + '200': + description: CDN metadata + headers: + Cache-Control: + schema: + type: string + description: no-cache (metadata should be fetched fresh) + content: + application/json: + schema: + $ref: '#/components/schemas/CDNMetadata' + '404': + description: Metadata file not found + + /{network}Net_{index}.headers: + get: + summary: CDN header file + description: | + Returns binary header file containing up to 100,000 block headers. + Each header is 80 bytes, so a full file is approximately 8MB. + Served from CDN server (default port 3012). + + Supports HTTP Range requests for partial downloads. + tags: + - CDN + parameters: + - name: network + in: path + required: true + schema: + type: string + enum: [main, test] + description: Network name (main or test) + - name: index + in: path + required: true + schema: + type: integer + minimum: 0 + description: File index (0-based). File 0 contains headers 0-99999, file 1 contains 100000-199999, etc. + responses: + '200': + description: Binary header data + headers: + Cache-Control: + schema: + type: string + description: public, max-age=3600 (immutable historical data) + Accept-Ranges: + schema: + type: string + description: bytes (supports Range requests) + content: + application/octet-stream: + schema: + type: string + format: binary + description: Concatenated block headers (80 bytes per header, up to 100k headers) + '206': + description: Partial content (when Range header is used) + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Header file not found + + /health: + get: + summary: CDN health check + description: | + Health check endpoint for the CDN server. + Served from CDN server (default port 3012). + tags: + - CDN + responses: + '200': + description: CDN server is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + service: + type: string + example: cdn + +components: + schemas: + SuccessResponse: + type: object + required: + - status + properties: + status: + type: string + enum: [success] + value: + description: Response payload (varies by endpoint) + + LegacySuccessResponse: + type: object + required: + - status + properties: + status: + type: string + enum: [success] + value: + description: Response payload (varies by endpoint) + + ErrorResponse: + type: object + required: + - status + - code + - description + properties: + status: + type: string + enum: [error] + code: + type: string + description: + type: string + + BlockHeader: + type: object + required: + - version + - previousHash + - merkleRoot + - time + - bits + - nonce + - height + - hash + properties: + version: + type: integer + format: int32 + description: Block version + previousHash: + type: string + description: Previous block hash + merkleRoot: + type: string + description: Merkle root hash + time: + type: integer + format: uint32 + description: Block timestamp (Unix time) + bits: + type: integer + format: uint32 + description: Difficulty target + nonce: + type: integer + format: uint32 + description: Nonce + height: + type: integer + format: uint32 + description: Block height in the chain + hash: + type: string + description: Block hash + + CDNMetadata: + type: object + required: + - rootFolder + - jsonFilename + - headersPerFile + - files + properties: + rootFolder: + type: string + description: Root folder path for header files + example: "" + jsonFilename: + type: string + description: Name of this metadata JSON file + example: "mainNetBlockHeaders.json" + headersPerFile: + type: integer + description: Number of headers per binary file (typically 100000) + example: 100000 + files: + type: array + items: + $ref: '#/components/schemas/CDNFileEntry' + description: List of available header files with metadata + + CDNFileEntry: + type: object + required: + - chain + - count + - fileHash + - fileName + - firstHeight + - lastChainWork + - lastHash + - prevChainWork + - prevHash + - sourceUrl + properties: + chain: + type: string + description: Network name (main or test) + example: "main" + count: + type: integer + description: Number of headers in this file + example: 100000 + fileHash: + type: string + description: SHA256 hash of the file contents + fileName: + type: string + description: Name of the binary header file + example: "mainNet_0.headers" + firstHeight: + type: integer + format: uint32 + description: Height of the first header in this file + example: 0 + lastChainWork: + type: string + description: Cumulative chainwork at the last header (hex string) + lastHash: + type: string + description: Hash of the last header in this file + prevChainWork: + type: string + description: Cumulative chainwork before the first header (hex string) + prevHash: + type: string + description: Hash of the block before the first header in this file + sourceUrl: + type: string + description: URL where this file can be downloaded + example: "https://chaintracks-cdn-us-1.bsvb.tech/mainNet_0.headers" From cfb58430e695a0463f0ddcbb76369f9fc7df3aff Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 24 Feb 2026 10:05:56 -0600 Subject: [PATCH 6/7] fix(lint): refactor openapi merge to fix gocyclo, nestif, and shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Break mergeOpenAPISpecs (complexity 41) into focused helpers: parseSpecs, getOrCreateMap, mergeChainstacksPaths, isLegacyPathItem, isLegacyOperation, buildPrefixedPath, mergeChaintracksTags, prefixChaintracksTag, mergeChaintracksSchemas, updatePrefixedPathTags, updateOperationTags, updateSchemaRef — each under gocyclo threshold - Replace chained if/else-if on tagStr with switch (QF1003 staticcheck) - Extract updateSchemaRef helper to flatten nestif in updateSchemaRefs - Eliminate ok variable shadowing by scoping to individual functions - Add nolint:gochecknoglobals on embed var (required by Go embed spec) - Refactor TestMergeOpenAPISpecs: extract hasChaintracksPath and hasPrefixedChaintracksTag helpers to reduce cyclomatic complexity Co-Authored-By: Claude Sonnet 4.6 --- cmd/arcade/openapi.go | 296 ++++++++++++++++++++++--------------- cmd/arcade/openapi_test.go | 116 ++++++++------- 2 files changed, 241 insertions(+), 171 deletions(-) diff --git a/cmd/arcade/openapi.go b/cmd/arcade/openapi.go index 9f9b863..6f8a70a 100644 --- a/cmd/arcade/openapi.go +++ b/cmd/arcade/openapi.go @@ -10,7 +10,9 @@ import ( ) //go:embed static/chaintracks-openapi.yaml -var chaintracksOpenAPIYAML []byte +var chaintracksOpenAPIYAML []byte //nolint:gochecknoglobals // embed directive requires package-level var + +const schemaRefPrefix = "#/components/schemas/" // OpenAPI Spec Merging // @@ -31,172 +33,217 @@ var chaintracksOpenAPIYAML []byte // mergeOpenAPISpecs merges the arcade and chaintracks OpenAPI specifications. // It prefixes all chaintracks paths with the given pathPrefix. func mergeOpenAPISpecs(arcadeJSON string, pathPrefix string) (string, error) { - // Parse arcade spec (JSON from swagger) + arcadeSpec, chaintracksSpec, err := parseSpecs(arcadeJSON) + if err != nil { + return "", err + } + + arcadePaths := getOrCreateMap(arcadeSpec, "paths") + mergeChainstacksPaths(arcadePaths, chaintracksSpec, pathPrefix) + mergeChaintracksTags(arcadeSpec, chaintracksSpec) + mergeChaintracksSchemas(arcadeSpec, chaintracksSpec) + updatePrefixedPathTags(arcadePaths, pathPrefix) + + mergedJSON, err := json.MarshalIndent(arcadeSpec, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal merged spec: %w", err) + } + + return string(mergedJSON), nil +} + +func parseSpecs(arcadeJSON string) (map[string]interface{}, map[string]interface{}, error) { var arcadeSpec map[string]interface{} if err := json.Unmarshal([]byte(arcadeJSON), &arcadeSpec); err != nil { - return "", fmt.Errorf("failed to parse arcade spec: %w", err) + return nil, nil, fmt.Errorf("failed to parse arcade spec: %w", err) } - // Parse chaintracks spec (YAML) var chaintracksSpec map[string]interface{} if err := yaml.Unmarshal(chaintracksOpenAPIYAML, &chaintracksSpec); err != nil { - return "", fmt.Errorf("failed to parse chaintracks spec: %w", err) + return nil, nil, fmt.Errorf("failed to parse chaintracks spec: %w", err) } - // Get or create paths map in arcade spec - arcadePaths, ok := arcadeSpec["paths"].(map[string]interface{}) + return arcadeSpec, chaintracksSpec, nil +} + +func getOrCreateMap(parent map[string]interface{}, key string) map[string]interface{} { + m, ok := parent[key].(map[string]interface{}) if !ok { - arcadePaths = make(map[string]interface{}) - arcadeSpec["paths"] = arcadePaths + m = make(map[string]interface{}) + parent[key] = m } - // Merge chaintracks paths with prefix - if chaintracksPaths, ok := chaintracksSpec["paths"].(map[string]interface{}); ok { - for path, pathItem := range chaintracksPaths { - // Skip CDN-only health endpoint (arcade has its own /health) - if isCDNOnlyPath(path) { - continue - } + return m +} - // Determine if this is a v1 legacy path by checking its tags - isLegacyPath := false - if pathItemMap, ok := pathItem.(map[string]interface{}); ok { - for _, operation := range pathItemMap { - if opMap, ok := operation.(map[string]interface{}); ok { - if tags, ok := opMap["tags"].([]interface{}); ok { - for _, tag := range tags { - if tagStr, ok := tag.(string); ok && tagStr == "Legacy" { - isLegacyPath = true - break - } - } - } - } - if isLegacyPath { - break - } - } - } +func mergeChainstacksPaths(arcadePaths map[string]interface{}, chaintracksSpec map[string]interface{}, pathPrefix string) { + chaintracksPaths, ok := chaintracksSpec["paths"].(map[string]interface{}) + if !ok { + return + } - // Add appropriate prefix based on path type - var prefixedPath string - if isLegacyPath { - // v1 legacy paths need /v1 added - prefixedPath = pathPrefix + "/v1" + path - } else if strings.HasPrefix(path, "/v2/") { - // v2 paths already have /v2 prefix - prefixedPath = pathPrefix + path - } else { - // Other paths (shouldn't happen after CDN filtering) - prefixedPath = pathPrefix + path - } + for path, pathItem := range chaintracksPaths { + if isCDNOnlyPath(path) { + continue + } + + isLegacy := isLegacyPathItem(pathItem) + arcadePaths[buildPrefixedPath(path, pathPrefix, isLegacy)] = pathItem + } +} - arcadePaths[prefixedPath] = pathItem +func isLegacyPathItem(pathItem interface{}) bool { + pathItemMap, ok := pathItem.(map[string]interface{}) + if !ok { + return false + } + + for _, operation := range pathItemMap { + if isLegacyOperation(operation) { + return true } } - // Merge tags - arcadeTags, _ := arcadeSpec["tags"].([]interface{}) - if chaintracksTags, ok := chaintracksSpec["tags"].([]interface{}); ok { - // Add chaintracks tags with a prefix to avoid confusion - for _, tag := range chaintracksTags { - if tagMap, ok := tag.(map[string]interface{}); ok { - // Prefix tag name with "chaintracks-" - if name, ok := tagMap["name"].(string); ok { - // Rename "Legacy" to "v1" for consistency - if name == "Legacy" { - tagMap["name"] = "chaintracks-v1" - } else { - tagMap["name"] = "chaintracks-" + name - } - } - arcadeTags = append(arcadeTags, tagMap) - } + return false +} + +func isLegacyOperation(operation interface{}) bool { + opMap, ok := operation.(map[string]interface{}) + if !ok { + return false + } + + tags, ok := opMap["tags"].([]interface{}) + if !ok { + return false + } + + for _, tag := range tags { + if tagStr, ok := tag.(string); ok && tagStr == "Legacy" { + return true } - arcadeSpec["tags"] = arcadeTags } - // Merge components/schemas if they exist - arcadeComponents, ok := arcadeSpec["components"].(map[string]interface{}) + return false +} + +func buildPrefixedPath(path, pathPrefix string, isLegacy bool) string { + if isLegacy { + return pathPrefix + "/v1" + path + } + + return pathPrefix + path +} + +func mergeChaintracksTags(arcadeSpec, chaintracksSpec map[string]interface{}) { + chaintracksTags, ok := chaintracksSpec["tags"].([]interface{}) if !ok { - arcadeComponents = make(map[string]interface{}) - arcadeSpec["components"] = arcadeComponents + return } - if chaintracksComponents, ok := chaintracksSpec["components"].(map[string]interface{}); ok { - if chaintracksSchemas, ok := chaintracksComponents["schemas"].(map[string]interface{}); ok { - arcadeSchemas, ok := arcadeComponents["schemas"].(map[string]interface{}) - if !ok { - arcadeSchemas = make(map[string]interface{}) - arcadeComponents["schemas"] = arcadeSchemas - } + arcadeTags, _ := arcadeSpec["tags"].([]interface{}) - // Prefix chaintracks schema names to avoid conflicts - for schemaName, schema := range chaintracksSchemas { - arcadeSchemas["Chaintracks"+schemaName] = schema - } + for _, tag := range chaintracksTags { + tagMap, ok := tag.(map[string]interface{}) + if !ok { + continue + } + + if name, ok := tagMap["name"].(string); ok { + tagMap["name"] = prefixChaintracksTag(name) } + + arcadeTags = append(arcadeTags, tagMap) + } + + arcadeSpec["tags"] = arcadeTags +} + +func prefixChaintracksTag(name string) string { + if name == "Legacy" { + return "chaintracks-v1" } - // Update all chaintracks path references to use prefixed tags + return "chaintracks-" + name +} + +func mergeChaintracksSchemas(arcadeSpec, chaintracksSpec map[string]interface{}) { + chaintracksComponents, ok := chaintracksSpec["components"].(map[string]interface{}) + if !ok { + return + } + + chaintracksSchemas, ok := chaintracksComponents["schemas"].(map[string]interface{}) + if !ok { + return + } + + arcadeComponents := getOrCreateMap(arcadeSpec, "components") + arcadeSchemas := getOrCreateMap(arcadeComponents, "schemas") + + for schemaName, schema := range chaintracksSchemas { + arcadeSchemas["Chaintracks"+schemaName] = schema + } +} + +func updatePrefixedPathTags(arcadePaths map[string]interface{}, pathPrefix string) { for path, pathItem := range arcadePaths { - if pathItemMap, ok := pathItem.(map[string]interface{}); ok { - for method, operation := range pathItemMap { - if operationMap, ok := operation.(map[string]interface{}); ok { - if tags, ok := operationMap["tags"].([]interface{}); ok { - for i, tag := range tags { - if tagStr, ok := tag.(string); ok { - // If it's a chaintracks tag, prefix it (rename Legacy to v1) - if tagStr == "v2" || tagStr == "CDN" { - tags[i] = "chaintracks-" + tagStr - } else if tagStr == "Legacy" { - tags[i] = "chaintracks-v1" - } - } - } - } - - // Update schema references for chaintracks endpoints - if pathPrefix != "" && len(path) >= len(pathPrefix) && path[:len(pathPrefix)] == pathPrefix { - updateSchemaRefs(operationMap, "Chaintracks") - } - - pathItemMap[method] = operationMap - } + if !strings.HasPrefix(path, pathPrefix) { + continue + } + + pathItemMap, ok := pathItem.(map[string]interface{}) + if !ok { + continue + } + + for method, operation := range pathItemMap { + operationMap, ok := operation.(map[string]interface{}) + if !ok { + continue } + + updateOperationTags(operationMap) + updateSchemaRefs(operationMap, "Chaintracks") + pathItemMap[method] = operationMap } } +} - // Marshal back to JSON - mergedJSON, err := json.MarshalIndent(arcadeSpec, "", " ") - if err != nil { - return "", fmt.Errorf("failed to marshal merged spec: %w", err) +func updateOperationTags(operationMap map[string]interface{}) { + tags, ok := operationMap["tags"].([]interface{}) + if !ok { + return } - return string(mergedJSON), nil + for i, tag := range tags { + tagStr, ok := tag.(string) + if !ok { + continue + } + + switch tagStr { + case "v2", "CDN": + tags[i] = "chaintracks-" + tagStr + case "Legacy": + tags[i] = "chaintracks-v1" + } + } } // isCDNOnlyPath returns true if the path is a CDN-only endpoint that should be excluded. // We exclude the CDN health check since arcade has its own /health endpoint. func isCDNOnlyPath(path string) bool { - // Exclude only the CDN-specific health check - // (We DO serve the CDN bootstrap files via static file serving) return path == "/health" // CDN health check - arcade has its own /health } -// updateSchemaRefs recursively updates schema references by adding a prefix +// updateSchemaRefs recursively updates schema references by adding a prefix. func updateSchemaRefs(obj interface{}, prefix string) { switch v := obj.(type) { case map[string]interface{}: for key, val := range v { if key == "$ref" { - if refStr, ok := val.(string); ok { - // Update component schema references - if len(refStr) > 21 && refStr[:21] == "#/components/schemas/" { - schemaName := refStr[21:] - v[key] = "#/components/schemas/" + prefix + schemaName - } - } + updateSchemaRef(v, key, val, prefix) } else { updateSchemaRefs(val, prefix) } @@ -207,3 +254,12 @@ func updateSchemaRefs(obj interface{}, prefix string) { } } } + +func updateSchemaRef(m map[string]interface{}, key string, val interface{}, prefix string) { + refStr, ok := val.(string) + if !ok || !strings.HasPrefix(refStr, schemaRefPrefix) { + return + } + + m[key] = schemaRefPrefix + prefix + refStr[len(schemaRefPrefix):] +} diff --git a/cmd/arcade/openapi_test.go b/cmd/arcade/openapi_test.go index 29284e6..08fb972 100644 --- a/cmd/arcade/openapi_test.go +++ b/cmd/arcade/openapi_test.go @@ -2,79 +2,93 @@ package main import ( "encoding/json" + "strings" "testing" ) -func TestMergeOpenAPISpecs(t *testing.T) { - // Sample arcade spec (minimal) - arcadeSpec := `{ - "swagger": "2.0", - "info": { - "title": "Arcade API", - "version": "0.1.0" - }, - "paths": { - "/tx": { - "post": { - "tags": ["arcade"], - "summary": "Submit transaction" - } +const testArcadeSpec = `{ + "swagger": "2.0", + "info": { + "title": "Arcade API", + "version": "0.1.0" + }, + "paths": { + "/tx": { + "post": { + "tags": ["arcade"], + "summary": "Submit transaction" } - }, - "tags": [ - {"name": "arcade", "description": "Arcade endpoints"} - ] - }` - - // Merge with chaintracks - merged, err := mergeOpenAPISpecs(arcadeSpec, "/chaintracks") + } + }, + "tags": [ + {"name": "arcade", "description": "Arcade endpoints"} + ] +}` + +func mergeAndParse(t *testing.T) map[string]interface{} { + t.Helper() + + merged, err := mergeOpenAPISpecs(testArcadeSpec, "/chaintracks") if err != nil { t.Fatalf("Failed to merge specs: %v", err) } - // Parse merged spec var spec map[string]interface{} if err := json.Unmarshal([]byte(merged), &spec); err != nil { t.Fatalf("Failed to parse merged spec: %v", err) } - // Verify arcade paths still exist - paths := spec["paths"].(map[string]interface{}) - if _, ok := paths["/tx"]; !ok { - t.Error("Arcade /tx path not found in merged spec") - } + return spec +} - // Verify chaintracks paths were added with prefix - foundChaintracksPaths := false +func hasChaintracksPath(paths map[string]interface{}) bool { for path := range paths { - if len(path) >= 12 && path[:12] == "/chaintracks" { - foundChaintracksPaths = true - break + if strings.HasPrefix(path, "/chaintracks") { + return true } } - if !foundChaintracksPaths { - t.Error("No chaintracks paths found with /chaintracks prefix") - } - // Verify tags were merged - tags := spec["tags"].([]interface{}) - if len(tags) < 2 { - t.Error("Expected at least 2 tags after merge") - } + return false +} - // Verify chaintracks tags were prefixed - foundPrefixedTag := false +func hasPrefixedChaintracksTag(tags []interface{}) bool { for _, tag := range tags { - if tagMap, ok := tag.(map[string]interface{}); ok { - if name, ok := tagMap["name"].(string); ok { - if len(name) >= 12 && name[:12] == "chaintracks-" { - foundPrefixedTag = true - break - } - } + tagMap, ok := tag.(map[string]interface{}) + if !ok { + continue + } + + name, ok := tagMap["name"].(string) + if ok && strings.HasPrefix(name, "chaintracks-") { + return true } } - if !foundPrefixedTag { + + return false +} + +func TestMergeOpenAPISpecs(t *testing.T) { + spec := mergeAndParse(t) + + paths, ok := spec["paths"].(map[string]interface{}) + if !ok { + t.Fatal("paths not found in merged spec") + } + + if _, ok := paths["/tx"]; !ok { + t.Error("Arcade /tx path not found in merged spec") + } + + if !hasChaintracksPath(paths) { + t.Error("No chaintracks paths found with /chaintracks prefix") + } + + tags, ok := spec["tags"].([]interface{}) + if !ok || len(tags) < 2 { + t.Errorf("Expected at least 2 tags after merge, got %d", len(tags)) + } + + if !hasPrefixedChaintracksTag(tags) { t.Error("No chaintracks- prefixed tags found") } From 8c845916d185d22cde9b809db3106b6c702b87db Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 24 Feb 2026 10:29:46 -0600 Subject: [PATCH 7/7] fix(lint): remove unused nolint directive and fix ok variable shadows - Remove //nolint:gochecknoglobals on embed var (linter exempts it automatically, making the directive itself flagged as unused by nolintlint) - Rename outer ok to hasTags in mergeChaintracksTags to avoid shadowing by inner ok declarations in the for loop body - Rename ok to hasTx in TestMergeOpenAPISpecs to avoid shadowing the paths, ok := declaration at line 73 (govet shadow) Co-Authored-By: Claude Sonnet 4.6 --- cmd/arcade/openapi.go | 6 +++--- cmd/arcade/openapi_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/arcade/openapi.go b/cmd/arcade/openapi.go index 6f8a70a..9e8338a 100644 --- a/cmd/arcade/openapi.go +++ b/cmd/arcade/openapi.go @@ -10,7 +10,7 @@ import ( ) //go:embed static/chaintracks-openapi.yaml -var chaintracksOpenAPIYAML []byte //nolint:gochecknoglobals // embed directive requires package-level var +var chaintracksOpenAPIYAML []byte const schemaRefPrefix = "#/components/schemas/" @@ -136,8 +136,8 @@ func buildPrefixedPath(path, pathPrefix string, isLegacy bool) string { } func mergeChaintracksTags(arcadeSpec, chaintracksSpec map[string]interface{}) { - chaintracksTags, ok := chaintracksSpec["tags"].([]interface{}) - if !ok { + chaintracksTags, hasTags := chaintracksSpec["tags"].([]interface{}) + if !hasTags { return } diff --git a/cmd/arcade/openapi_test.go b/cmd/arcade/openapi_test.go index 08fb972..395ab27 100644 --- a/cmd/arcade/openapi_test.go +++ b/cmd/arcade/openapi_test.go @@ -75,7 +75,7 @@ func TestMergeOpenAPISpecs(t *testing.T) { t.Fatal("paths not found in merged spec") } - if _, ok := paths["/tx"]; !ok { + if _, hasTx := paths["/tx"]; !hasTx { t.Error("Arcade /tx path not found in merged spec") }