refactor: rename SolaceScanScout to Solace and update related configurations

- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation.
- Changed default base URL for Playwright tests and updated security headers to reflect the new branding.
- Enhanced README and API documentation to include new authentication endpoints and product access details.

This refactor aligns the project branding and improves clarity in the API documentation.
This commit is contained in:
defiQUG
2026-04-10 12:52:17 -07:00
parent bdae5a9f6e
commit f46bd213ba
160 changed files with 13274 additions and 1061 deletions

View File

@@ -78,7 +78,7 @@ func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc {
}
// Add branding header
w.Header().Set("X-Explorer-Name", "SolaceScanScout")
w.Header().Set("X-Explorer-Name", "SolaceScan")
w.Header().Set("X-Explorer-Version", "1.0.0")
// Proxy request

View File

@@ -20,7 +20,7 @@ func (m *SecurityMiddleware) AddSecurityHeaders(next http.Handler) http.Handler
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Content Security Policy
// unsafe-eval required by ethers.js v5 UMD from CDN (ABI decoding)
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://blockscout.defi-oracle.io https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;")
// X-Frame-Options (click-jacking protection)
w.Header().Set("X-Frame-Options", "DENY")

View File

@@ -6,6 +6,7 @@ REST API implementation for the ChainID 138 Explorer Platform.
- `server.go` - Main server setup and route configuration
- `routes.go` - Route handlers and URL parsing
- `auth.go` - Wallet auth, user-session auth, RPC product access, subscriptions, and API keys
- `blocks.go` - Block-related endpoints
- `transactions.go` - Transaction-related endpoints
- `addresses.go` - Address-related endpoints
@@ -17,6 +18,12 @@ REST API implementation for the ChainID 138 Explorer Platform.
## API Endpoints
### Auth
- `POST /api/v1/auth/nonce` - Create a wallet-signature nonce
- `POST /api/v1/auth/wallet` - Authenticate a wallet and receive a track JWT
- `POST /api/v1/auth/register` - Create an access-console user session
- `POST /api/v1/auth/login` - Log in to the access console
### Blocks
- `GET /api/v1/blocks` - List blocks (paginated)
- `GET /api/v1/blocks/{chain_id}/{number}` - Get block by number
@@ -40,6 +47,23 @@ REST API implementation for the ChainID 138 Explorer Platform.
- `GET /api/v1/mission-control/bridge/trace?tx=0x...` - Blockscout-backed tx trace with Chain 138 contract labels
- `GET /api/v1/mission-control/liquidity/token/{address}/pools` - 30-second cached proxy to token-aggregation pools
### Access and API keys
- `GET /api/v1/access/me` - Current signed-in access user and subscriptions
- `GET /api/v1/access/products` - RPC product catalog for Core, Alltra, and Thirdweb lanes
- `GET /api/v1/access/subscriptions` - List product subscriptions
- `POST /api/v1/access/subscriptions` - Request or activate a product subscription
- `GET /api/v1/access/admin/subscriptions` - List pending or filtered subscriptions for admin review
- `POST /api/v1/access/admin/subscriptions` - Approve, suspend, or revoke a subscription as an admin
- `GET /api/v1/access/api-keys` - List issued API keys
- `POST /api/v1/access/api-keys` - Create an API key for a tier, product, scopes, expiry, and optional quota override
- `POST /api/v1/access/api-keys/{id}` - Revoke an API key
- `DELETE /api/v1/access/api-keys/{id}` - Alternate revoke verb
- `GET /api/v1/access/usage` - Per-product usage summary
- `GET /api/v1/access/audit` - Recent validated API-key usage rows for the signed-in user
- `GET /api/v1/access/admin/audit` - Admin view of recent validated API-key usage rows, optionally filtered by product
- `POST /api/v1/access/internal/validate-key` - Internal edge validation hook for API-key enforcement and usage logging
- `GET /api/v1/access/internal/validate-key` - `auth_request`-friendly validator for nginx or similar proxies
### Track 4 operator
- `POST /api/v1/track4/operator/run-script` - Run an allowlisted script under `OPERATOR_SCRIPTS_ROOT`
@@ -52,6 +76,9 @@ REST API implementation for the ChainID 138 Explorer Platform.
- Request logging
- Error handling with consistent error format
- Health checks with database connectivity
- Wallet JWT auth for track endpoints
- Email/password user sessions for the explorer access console
- RPC product catalog, subscription state, API key issuance, revocation, and usage summaries
## Running
@@ -85,6 +112,66 @@ Set environment variables:
- `OPERATOR_SCRIPTS_ROOT` - Root directory for allowlisted Track 4 scripts
- `OPERATOR_SCRIPT_ALLOWLIST` - Comma-separated list of permitted script names or relative paths
- `OPERATOR_SCRIPT_TIMEOUT_SEC` - Optional Track 4 script timeout in seconds (max 599)
- `JWT_SECRET` - Shared secret for wallet and user-session JWT signing
- `ACCESS_ADMIN_EMAILS` - Comma-separated email allowlist for access-console admins
- `ACCESS_INTERNAL_SECRET` - Shared secret used by internal edge validators calling `/api/v1/access/internal/validate-key`
## Auth model
There are now two distinct auth planes:
1. Wallet auth
- `POST /api/v1/auth/nonce`
- `POST /api/v1/auth/wallet`
- Used for wallet-oriented explorer tracks and operator features.
2. Access-console user auth
- `POST /api/v1/auth/register`
- `POST /api/v1/auth/login`
- Used for `/api/v1/access/*` endpoints and the frontend `/access` console.
## RPC access model
The access layer currently models three RPC products:
- `core-rpc`
- Provider: `besu-core`
- VMID: `2101`
- Approval required
- Intended for operator-grade and sensitive use
- `alltra-rpc`
- Provider: `alltra`
- VMID: `2102`
- Self-service subscription model
- `thirdweb-rpc`
- Provider: `thirdweb`
- VMID: `2103`
- Self-service subscription model
The explorer can now:
- register and authenticate users
- publish an RPC product catalog
- create product subscriptions
- issue scoped API keys
- set expiry presets and quota overrides
- rotate keys by minting a replacement and revoking the old one
- review approval-gated subscriptions through an admin surface
- revoke keys
- show usage summaries
- show recent audit activity for users and admins
- validate keys for internal edge enforcement and append usage records
- support nginx `auth_request` integration through the `GET /api/v1/access/internal/validate-key` form
Current limitation:
- the internal validation hook exists, but nginx/Besu/relay still need to call it or replicate its rules to enforce traffic at the edge
- billing collection and invoicing are not yet handled by this package
Operational reference:
- `explorer-monorepo/deployment/ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md`
- `explorer-monorepo/deployment/common/nginx-rpc-api-key-gate.conf`
## Mission-control deployment notes

View File

@@ -241,7 +241,7 @@ func (s *Server) buildAIContext(ctx context.Context, query string, pageContext m
warnings := []string{}
envelope := AIContextEnvelope{
ChainID: s.chainID,
Explorer: "SolaceScanScout",
Explorer: "SolaceScan",
PageContext: compactStringMap(pageContext),
CapabilityNotice: "This assistant is wired for read-only explorer analysis. It can summarize indexed chain data, liquidity routes, and curated workspace docs, but it does not sign transactions or execute private operations.",
}
@@ -899,7 +899,7 @@ func (s *Server) callXAIChatCompletions(ctx context.Context, messages []AIChatMe
contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ")
contextText := clipString(string(contextJSON), maxExplorerAIContextChars)
baseSystem := "You are the SolaceScanScout ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing."
baseSystem := "You are the SolaceScan ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing."
if !explorerAIOperatorToolsEnabled() {
baseSystem += " Never instruct users to paste private keys or seed phrases. Do not direct users to run privileged mint, liquidity, or bridge execution from the public explorer UI. Operator changes belong on LAN-gated workflows and authenticated Track 4 APIs; PMM/MCP-style execution tools are disabled on this deployment unless EXPLORER_AI_OPERATOR_TOOLS_ENABLED=1."
}

View File

@@ -246,6 +246,86 @@ func TestAuthWalletRequiresDB(t *testing.T) {
assert.NotNil(t, response["error"])
}
func TestAccessProductsEndpoint(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/products", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotNil(t, response["products"])
}
func TestAccessMeRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/me", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotNil(t, response["error"])
}
func TestAccessSubscriptionsRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/subscriptions", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAccessUsageRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/usage", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAccessAuditRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/audit", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAccessAdminAuditRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/admin/audit", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAccessInternalValidateKeyRequiresDB(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/internal/validate-key", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAIContextEndpoint(t *testing.T) {
_, mux := setupTestServer(t)

View File

@@ -3,9 +3,16 @@ package rest
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/explorer/backend/auth"
"github.com/golang-jwt/jwt/v4"
)
// handleAuthNonce handles POST /api/v1/auth/nonce
@@ -69,3 +76,851 @@ func (s *Server) handleAuthWallet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(authResp)
}
type userAuthRequest struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
type accessProduct struct {
Slug string `json:"slug"`
Name string `json:"name"`
Provider string `json:"provider"`
VMID int `json:"vmid"`
HTTPURL string `json:"http_url"`
WSURL string `json:"ws_url,omitempty"`
DefaultTier string `json:"default_tier"`
RequiresApproval bool `json:"requires_approval"`
BillingModel string `json:"billing_model"`
Description string `json:"description"`
UseCases []string `json:"use_cases"`
ManagementFeatures []string `json:"management_features"`
}
type userSessionClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
jwt.RegisteredClaims
}
type createAPIKeyRequest struct {
Name string `json:"name"`
Tier string `json:"tier"`
ProductSlug string `json:"product_slug"`
ExpiresDays int `json:"expires_days"`
MonthlyQuota int `json:"monthly_quota"`
Scopes []string `json:"scopes"`
}
type createSubscriptionRequest struct {
ProductSlug string `json:"product_slug"`
Tier string `json:"tier"`
}
type accessUsageSummary struct {
ProductSlug string `json:"product_slug"`
ActiveKeys int `json:"active_keys"`
RequestsUsed int `json:"requests_used"`
MonthlyQuota int `json:"monthly_quota"`
}
type accessAuditEntry = auth.APIKeyUsageLog
type adminSubscriptionActionRequest struct {
SubscriptionID string `json:"subscription_id"`
Status string `json:"status"`
Notes string `json:"notes"`
}
type internalValidateAPIKeyRequest struct {
APIKey string `json:"api_key"`
MethodName string `json:"method_name"`
RequestCount int `json:"request_count"`
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"},
},
}
func (s *Server) generateUserJWT(user *auth.User) (string, time.Time, error) {
expiresAt := time.Now().Add(7 * 24 * time.Hour)
claims := userSessionClaims{
UserID: user.ID,
Email: user.Email,
Username: user.Username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user.ID,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(s.jwtSecret)
if err != nil {
return "", time.Time{}, err
}
return tokenString, expiresAt, nil
}
func (s *Server) validateUserJWT(tokenString string) (*userSessionClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &userSessionClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*userSessionClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ""
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}
func (s *Server) requireUserSession(w http.ResponseWriter, r *http.Request) (*userSessionClaims, bool) {
token := extractBearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "User session required")
return nil, false
}
claims, err := s.validateUserJWT(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired session token")
return nil, false
}
return claims, true
}
func isEmailInCSVAllowlist(email string, raw string) bool {
if strings.TrimSpace(email) == "" || strings.TrimSpace(raw) == "" {
return false
}
for _, candidate := range strings.Split(raw, ",") {
if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(email)) {
return true
}
}
return false
}
func (s *Server) isAccessAdmin(claims *userSessionClaims) bool {
return claims != nil && isEmailInCSVAllowlist(claims.Email, os.Getenv("ACCESS_ADMIN_EMAILS"))
}
func (s *Server) requireInternalAccessSecret(w http.ResponseWriter, r *http.Request) bool {
configured := strings.TrimSpace(os.Getenv("ACCESS_INTERNAL_SECRET"))
if configured == "" {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Internal access secret is not configured")
return false
}
presented := strings.TrimSpace(r.Header.Get("X-Access-Internal-Secret"))
if presented == "" || presented != configured {
writeError(w, http.StatusUnauthorized, "unauthorized", "Internal access secret required")
return false
}
return true
}
func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if !s.requireDB(w) {
return
}
var req userAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Username) == "" || len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "bad_request", "Email, username, and an 8+ character password are required")
return
}
user, err := s.userAuth.RegisterUser(r.Context(), strings.TrimSpace(req.Email), strings.TrimSpace(req.Username), req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
token, expiresAt, err := s.generateUserJWT(user)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create session")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"username": user.Username,
},
"token": token,
"expires_at": expiresAt,
})
}
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if !s.requireDB(w) {
return
}
var req userAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
user, err := s.userAuth.AuthenticateUser(r.Context(), strings.TrimSpace(req.Email), req.Password)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
token, expiresAt, err := s.generateUserJWT(user)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create session")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"username": user.Username,
},
"token": token,
"expires_at": expiresAt,
})
}
func (s *Server) handleAccessProducts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"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.",
})
}
func (s *Server) handleAccessMe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
subscriptions, _ := s.userAuth.ListSubscriptions(r.Context(), claims.UserID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"user": map[string]any{
"id": claims.UserID,
"email": claims.Email,
"username": claims.Username,
"is_admin": s.isAccessAdmin(claims),
},
"subscriptions": subscriptions,
})
}
func (s *Server) handleAccessAPIKeys(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
switch r.Method {
case http.MethodGet:
keys, err := s.userAuth.ListAPIKeys(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"api_keys": keys})
case http.MethodPost:
var req createAPIKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Key name is required")
return
}
tier := strings.ToLower(strings.TrimSpace(req.Tier))
if tier == "" {
tier = "free"
}
productSlug := strings.TrimSpace(req.ProductSlug)
product := findAccessProduct(productSlug)
if productSlug != "" && product == nil {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown product")
return
}
subscriptions, err := s.userAuth.ListSubscriptions(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
var subscriptionStatus string
for _, subscription := range subscriptions {
if subscription.ProductSlug == productSlug {
subscriptionStatus = subscription.Status
break
}
}
if product != nil {
if subscriptionStatus == "" {
status := "active"
if product.RequiresApproval {
status = "pending"
}
_, err := s.userAuth.UpsertProductSubscription(
r.Context(),
claims.UserID,
productSlug,
tier,
status,
defaultQuotaForTier(tier),
product.RequiresApproval,
"",
"",
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
subscriptionStatus = status
}
if subscriptionStatus != "active" {
writeError(w, http.StatusForbidden, "subscription_required", "Product access is pending approval or inactive")
return
}
}
fullName := req.Name
if productSlug != "" {
fullName = fmt.Sprintf("%s [%s]", req.Name, productSlug)
}
monthlyQuota := req.MonthlyQuota
if monthlyQuota <= 0 {
monthlyQuota = defaultQuotaForTier(tier)
}
scopes := req.Scopes
if len(scopes) == 0 {
scopes = defaultScopesForProduct(productSlug)
}
apiKey, err := s.userAuth.GenerateScopedAPIKey(
r.Context(),
claims.UserID,
fullName,
tier,
productSlug,
scopes,
monthlyQuota,
product == nil || !product.RequiresApproval,
req.ExpiresDays,
)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
keys, _ := s.userAuth.ListAPIKeys(r.Context(), claims.UserID)
var latest any
if len(keys) > 0 {
latest = keys[0]
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"api_key": apiKey,
"record": latest,
})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
}
}
func (s *Server) handleAccessInternalValidateAPIKey(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if !s.requireInternalAccessSecret(w, r) {
return
}
req, err := parseInternalValidateAPIKeyRequest(r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if strings.TrimSpace(req.APIKey) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "API key is required")
return
}
info, err := s.userAuth.ValidateAPIKeyDetailed(
r.Context(),
strings.TrimSpace(req.APIKey),
strings.TrimSpace(req.MethodName),
req.RequestCount,
strings.TrimSpace(req.LastIP),
)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
w.Header().Set("X-Validated-Product", info.ProductSlug)
w.Header().Set("X-Validated-Tier", info.Tier)
w.Header().Set("X-Validated-User", info.UserID)
w.Header().Set("X-Validated-Scopes", strings.Join(info.Scopes, ","))
if info.MonthlyQuota > 0 {
remaining := info.MonthlyQuota - info.RequestsUsed
if remaining < 0 {
remaining = 0
}
w.Header().Set("X-Quota-Remaining", strconv.Itoa(remaining))
}
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"valid": true,
"key": info,
})
}
func parseInternalValidateAPIKeyRequest(r *http.Request) (internalValidateAPIKeyRequest, error) {
var req internalValidateAPIKeyRequest
if r.Method == http.MethodGet {
req.APIKey = firstNonEmpty(
r.Header.Get("X-API-Key"),
extractBearerToken(r),
r.URL.Query().Get("api_key"),
)
req.MethodName = firstNonEmpty(
r.Header.Get("X-Access-Method"),
r.URL.Query().Get("method_name"),
r.Method,
)
req.LastIP = firstNonEmpty(
r.Header.Get("X-Real-IP"),
r.Header.Get("X-Forwarded-For"),
r.URL.Query().Get("last_ip"),
)
req.RequestCount = 1
if rawCount := firstNonEmpty(r.Header.Get("X-Access-Request-Count"), r.URL.Query().Get("request_count")); rawCount != "" {
parsed, err := strconv.Atoi(strings.TrimSpace(rawCount))
if err != nil {
return req, fmt.Errorf("invalid request_count")
}
req.RequestCount = parsed
}
return req, nil
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if errors.Is(err, io.EOF) {
return req, fmt.Errorf("invalid request body")
}
return req, fmt.Errorf("invalid request body")
}
if strings.TrimSpace(req.MethodName) == "" {
req.MethodName = r.Method
}
return req, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func findAccessProduct(slug string) *accessProduct {
for _, product := range rpcAccessProducts {
if product.Slug == slug {
copy := product
return &copy
}
}
return nil
}
func defaultQuotaForTier(tier string) int {
switch tier {
case "enterprise":
return 1000000
case "pro":
return 100000
default:
return 10000
}
}
func defaultScopesForProduct(productSlug string) []string {
switch productSlug {
case "core-rpc":
return []string{"rpc:read", "rpc:write", "rpc:admin"}
case "alltra-rpc", "thirdweb-rpc":
return []string{"rpc:read", "rpc:write"}
default:
return []string{"rpc:read"}
}
}
func (s *Server) handleAccessSubscriptions(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
switch r.Method {
case http.MethodGet:
subscriptions, err := s.userAuth.ListSubscriptions(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"subscriptions": subscriptions})
case http.MethodPost:
var req createSubscriptionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
product := findAccessProduct(strings.TrimSpace(req.ProductSlug))
if product == nil {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown product")
return
}
tier := strings.ToLower(strings.TrimSpace(req.Tier))
if tier == "" {
tier = product.DefaultTier
}
status := "active"
notes := "Self-service activation"
if product.RequiresApproval {
status = "pending"
notes = "Awaiting manual approval for restricted product"
}
subscription, err := s.userAuth.UpsertProductSubscription(
r.Context(),
claims.UserID,
product.Slug,
tier,
status,
defaultQuotaForTier(tier),
product.RequiresApproval,
"",
notes,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"subscription": subscription})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
}
}
func (s *Server) handleAccessAdminSubscriptions(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if !s.isAccessAdmin(claims) {
writeError(w, http.StatusForbidden, "forbidden", "Access admin privileges required")
return
}
switch r.Method {
case http.MethodGet:
status := strings.TrimSpace(r.URL.Query().Get("status"))
subscriptions, err := s.userAuth.ListAllSubscriptions(r.Context(), status)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"subscriptions": subscriptions})
case http.MethodPost:
var req adminSubscriptionActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
status := strings.ToLower(strings.TrimSpace(req.Status))
switch status {
case "active", "suspended", "revoked":
default:
writeError(w, http.StatusBadRequest, "bad_request", "Status must be active, suspended, or revoked")
return
}
if strings.TrimSpace(req.SubscriptionID) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Subscription id is required")
return
}
subscription, err := s.userAuth.UpdateSubscriptionStatus(
r.Context(),
strings.TrimSpace(req.SubscriptionID),
status,
claims.Email,
strings.TrimSpace(req.Notes),
)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"subscription": subscription})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
}
}
func (s *Server) handleAccessUsage(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
keys, err := s.userAuth.ListAPIKeys(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
grouped := map[string]*accessUsageSummary{}
for _, key := range keys {
slug := key.ProductSlug
if slug == "" {
slug = "unscoped"
}
if _, ok := grouped[slug]; !ok {
grouped[slug] = &accessUsageSummary{ProductSlug: slug}
}
summary := grouped[slug]
if !key.Revoked {
summary.ActiveKeys++
}
summary.RequestsUsed += key.RequestsUsed
summary.MonthlyQuota += key.MonthlyQuota
}
summaries := make([]accessUsageSummary, 0, len(grouped))
for _, summary := range grouped {
summaries = append(summaries, *summary)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"usage": summaries})
}
func (s *Server) handleAccessAudit(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
limit := 20
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
parsed, err := strconv.Atoi(rawLimit)
if err != nil || parsed < 1 || parsed > 200 {
writeError(w, http.StatusBadRequest, "bad_request", "Limit must be between 1 and 200")
return
}
limit = parsed
}
entries, err := s.userAuth.ListUsageLogs(r.Context(), claims.UserID, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
}
func (s *Server) handleAccessAdminAudit(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if !s.isAccessAdmin(claims) {
writeError(w, http.StatusForbidden, "forbidden", "Access admin privileges required")
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
limit := 50
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
parsed, err := strconv.Atoi(rawLimit)
if err != nil || parsed < 1 || parsed > 500 {
writeError(w, http.StatusBadRequest, "bad_request", "Limit must be between 1 and 500")
return
}
limit = parsed
}
productSlug := strings.TrimSpace(r.URL.Query().Get("product"))
if productSlug != "" && findAccessProduct(productSlug) == nil {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown product")
return
}
entries, err := s.userAuth.ListAllUsageLogs(r.Context(), productSlug, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
}
func (s *Server) handleAccessAPIKeyAction(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/access/api-keys/")
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 0 || parts[0] == "" {
writeError(w, http.StatusBadRequest, "bad_request", "API key id is required")
return
}
keyID := parts[0]
if err := s.userAuth.RevokeAPIKey(r.Context(), claims.UserID, keyID); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"revoked": true,
"api_key_id": keyID,
})
}

View File

@@ -5,7 +5,7 @@
"minor": 1,
"patch": 0
},
"generatedBy": "SolaceScanScout",
"generatedBy": "SolaceScan",
"timestamp": "2026-03-28T00:00:00Z",
"chainId": 138,
"chainName": "DeFi Oracle Meta Mainnet",

View File

@@ -4,9 +4,9 @@
"defaultChainId": 138,
"explorerUrl": "https://explorer.d-bis.org",
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
"generatedBy": "SolaceScanScout",
"generatedBy": "SolaceScan",
"chains": [
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false},
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org","https://blockscout.defi-oracle.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false},
{"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","shortName":"eth","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://ethereum.org","testnet":false},
{"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","shortName":"all","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://alltra.global","testnet":false},
{"chainId":"0x19","chainIdDecimal":25,"chainName":"Cronos Mainnet","rpcUrls":["https://evm.cronos.org","https://cronos-rpc.publicnode.com"],"nativeCurrency":{"name":"CRO","symbol":"CRO","decimals":18},"blockExplorerUrls":["https://cronos.org/explorer"],"iconUrls":["https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong"]},

View File

@@ -52,6 +52,18 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
// Auth endpoints
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
mux.HandleFunc("/api/v1/auth/register", s.handleAuthRegister)
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
mux.HandleFunc("/api/v1/access/products", s.handleAccessProducts)
mux.HandleFunc("/api/v1/access/subscriptions", s.handleAccessSubscriptions)
mux.HandleFunc("/api/v1/access/admin/subscriptions", s.handleAccessAdminSubscriptions)
mux.HandleFunc("/api/v1/access/admin/audit", s.handleAccessAdminAudit)
mux.HandleFunc("/api/v1/access/internal/validate-key", s.handleAccessInternalValidateAPIKey)
mux.HandleFunc("/api/v1/access/api-keys", s.handleAccessAPIKeys)
mux.HandleFunc("/api/v1/access/api-keys/", s.handleAccessAPIKeyAction)
mux.HandleFunc("/api/v1/access/usage", s.handleAccessUsage)
mux.HandleFunc("/api/v1/access/audit", s.handleAccessAudit)
// Track 1 routes (public, optional auth)
// Note: Track 1 endpoints should be registered with OptionalAuth middleware

View File

@@ -22,6 +22,7 @@ import (
type Server struct {
db *pgxpool.Pool
chainID int
userAuth *auth.Auth
walletAuth *auth.WalletAuth
jwtSecret []byte
aiLimiter *AIRateLimiter
@@ -42,6 +43,7 @@ func NewServer(db *pgxpool.Pool, chainID int) *Server {
return &Server{
db: db,
chainID: chainID,
userAuth: auth.NewAuth(db),
walletAuth: walletAuth,
jwtSecret: jwtSecret,
aiLimiter: NewAIRateLimiter(),
@@ -74,7 +76,7 @@ func (s *Server) Start(port int) error {
// Security headers (reusable lib; CSP from env or explorer default)
csp := os.Getenv("CSP_HEADER")
if csp == "" {
csp = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;"
csp = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://blockscout.defi-oracle.io https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;"
}
securityMiddleware := httpmiddleware.NewSecurity(csp)
@@ -90,7 +92,7 @@ func (s *Server) Start(port int) error {
)
addr := fmt.Sprintf(":%d", port)
log.Printf("Starting SolaceScanScout REST API server on %s", addr)
log.Printf("Starting SolaceScan REST API server on %s", addr)
log.Printf("Tiered architecture enabled: Track 1 (public), Track 2-4 (authenticated)")
return http.ListenAndServe(addr, handler)
}
@@ -99,11 +101,11 @@ func (s *Server) Start(port int) error {
func (s *Server) addMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Add branding headers
w.Header().Set("X-Explorer-Name", "SolaceScanScout")
w.Header().Set("X-Explorer-Name", "SolaceScan")
w.Header().Set("X-Explorer-Version", "1.0.0")
w.Header().Set("X-Powered-By", "SolaceScanScout")
w.Header().Set("X-Powered-By", "SolaceScan")
// Add CORS headers for API routes (optional: set CORS_ALLOWED_ORIGIN to restrict, e.g. https://explorer.d-bis.org)
// Add CORS headers for API routes (optional: set CORS_ALLOWED_ORIGIN to restrict, e.g. https://blockscout.defi-oracle.io)
if strings.HasPrefix(r.URL.Path, "/api/") {
origin := os.Getenv("CORS_ALLOWED_ORIGIN")
if origin == "" {
@@ -224,7 +226,7 @@ func (s *Server) handleListBlocks(w http.ResponseWriter, r *http.Request) {
// handleHealth handles GET /health
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Explorer-Name", "SolaceScanScout")
w.Header().Set("X-Explorer-Name", "SolaceScan")
w.Header().Set("X-Explorer-Version", "1.0.0")
// Check database connection
@@ -248,7 +250,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
},
"chain_id": s.chainID,
"explorer": map[string]string{
"name": "SolaceScanScout",
"name": "SolaceScan",
"version": "1.0.0",
},
}

View File

@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: SolaceScanScout API
title: SolaceScan API
description: |
Blockchain Explorer API for ChainID 138 with tiered access control.
SolaceScan public explorer API for Chain 138 with tiered access control.
## Authentication
@@ -31,6 +31,10 @@ servers:
tags:
- name: Health
description: Health check endpoints
- name: Auth
description: Wallet and user-session authentication endpoints
- name: Access
description: RPC product catalog, subscriptions, and API key lifecycle
- name: Blocks
description: Block-related endpoints
- name: Transactions
@@ -76,6 +80,542 @@ paths:
type: string
example: connected
/api/v1/auth/nonce:
post:
tags:
- Auth
summary: Generate wallet auth nonce
description: Creates a nonce challenge for wallet-signature authentication.
operationId: createWalletAuthNonce
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WalletNonceRequest'
responses:
'200':
description: Nonce generated
content:
application/json:
schema:
$ref: '#/components/schemas/WalletNonceResponse'
'400':
$ref: '#/components/responses/BadRequest'
'503':
description: Wallet auth storage or database not available
/api/v1/auth/wallet:
post:
tags:
- Auth
summary: Authenticate with wallet signature
description: Exchanges an address, signature, and nonce for a JWT used by wallet-authenticated track endpoints.
operationId: authenticateWallet
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WalletAuthRequest'
responses:
'200':
description: Wallet authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/WalletAuthResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Wallet auth storage or database not available
/api/v1/auth/register:
post:
tags:
- Auth
summary: Register an explorer access user
description: "Creates an email/password account for the `/access` console and returns a user session token."
operationId: registerAccessUser
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserRegisterRequest'
responses:
'200':
description: User created and session issued
content:
application/json:
schema:
$ref: '#/components/schemas/UserSessionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'503':
description: Database not available
/api/v1/auth/login:
post:
tags:
- Auth
summary: Log in to the explorer access console
description: "Authenticates an email/password user and returns a user session token for `/api/v1/access/*` endpoints."
operationId: loginAccessUser
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserLoginRequest'
responses:
'200':
description: Session issued
content:
application/json:
schema:
$ref: '#/components/schemas/UserSessionResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/me:
get:
tags:
- Access
summary: Get current access-console user
description: Returns the signed-in user profile and any known product subscriptions.
operationId: getAccessMe
security:
- userSessionAuth: []
responses:
'200':
description: Current user and subscriptions
content:
application/json:
schema:
$ref: '#/components/schemas/AccessMeResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/products:
get:
tags:
- Access
summary: List available RPC access products
description: Returns the commercial and operational RPC products currently modeled by the explorer access layer.
operationId: listAccessProducts
responses:
'200':
description: Product catalog
content:
application/json:
schema:
$ref: '#/components/schemas/AccessProductsResponse'
/api/v1/access/subscriptions:
get:
tags:
- Access
summary: List subscriptions for the signed-in user
operationId: listAccessSubscriptions
security:
- userSessionAuth: []
responses:
'200':
description: Subscription list
content:
application/json:
schema:
$ref: '#/components/schemas/AccessSubscriptionsResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/admin/subscriptions:
get:
tags:
- Access
summary: List subscriptions for admin review
description: Returns pending or filtered subscriptions for users whose email is allowlisted in `ACCESS_ADMIN_EMAILS`.
operationId: listAccessAdminSubscriptions
security:
- userSessionAuth: []
parameters:
- name: status
in: query
required: false
schema:
type: string
enum: [pending, active, suspended, revoked]
responses:
'200':
description: Subscription list
content:
application/json:
schema:
$ref: '#/components/schemas/AccessSubscriptionsResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Admin privileges required
'503':
description: Database not available
post:
tags:
- Access
summary: Approve, suspend, or revoke a subscription
operationId: updateAccessAdminSubscription
security:
- userSessionAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AdminSubscriptionActionRequest'
responses:
'200':
description: Subscription updated
content:
application/json:
schema:
$ref: '#/components/schemas/AccessSubscriptionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Admin privileges required
'503':
description: Database not available
post:
tags:
- Access
summary: Request or activate product access
description: |
Creates or updates a product subscription. Self-service products become `active` immediately.
Approval-gated products such as Core RPC are created in `pending` state.
operationId: createAccessSubscription
security:
- userSessionAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateSubscriptionRequest'
responses:
'200':
description: Subscription saved
content:
application/json:
schema:
$ref: '#/components/schemas/AccessSubscriptionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/api-keys:
get:
tags:
- Access
summary: List API keys for the signed-in user
operationId: listAccessApiKeys
security:
- userSessionAuth: []
responses:
'200':
description: API key records
content:
application/json:
schema:
$ref: '#/components/schemas/AccessAPIKeysResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
post:
tags:
- Access
summary: Create an API key
description: |
Issues an API key for the chosen tier and product. If the product is approval-gated and not already active
for the user, this endpoint returns `subscription_required`.
operationId: createAccessApiKey
security:
- userSessionAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAPIKeyRequest'
responses:
'200':
description: API key created
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAPIKeyResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Product access is pending approval or inactive
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: subscription_required
message: Product access is pending approval or inactive
'503':
description: Database not available
/api/v1/access/api-keys/{id}:
post:
tags:
- Access
summary: Revoke an API key
description: "Revokes the identified API key. `DELETE` is also accepted by the handler, but the current frontend uses `POST`."
operationId: revokeAccessApiKey
security:
- userSessionAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: API key revoked
content:
application/json:
schema:
$ref: '#/components/schemas/RevokeAPIKeyResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
delete:
tags:
- Access
summary: Revoke an API key
description: Alternate HTTP verb for API key revocation.
operationId: revokeAccessApiKeyDelete
security:
- userSessionAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: API key revoked
content:
application/json:
schema:
$ref: '#/components/schemas/RevokeAPIKeyResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/usage:
get:
tags:
- Access
summary: Get usage summary for the signed-in user
description: Returns aggregated per-product usage derived from issued API keys.
operationId: getAccessUsage
security:
- userSessionAuth: []
responses:
'200':
description: Usage summary
content:
application/json:
schema:
$ref: '#/components/schemas/AccessUsageResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/audit:
get:
tags:
- Access
summary: Get recent API activity for the signed-in user
description: Returns recent validated API-key usage log rows for the current user.
operationId: getAccessAudit
security:
- userSessionAuth: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 20
responses:
'200':
description: Audit entries
content:
application/json:
schema:
$ref: '#/components/schemas/AccessAuditResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/admin/audit:
get:
tags:
- Access
summary: Get recent API activity across users for admin review
description: Returns recent validated API-key usage log rows for access admins, optionally filtered by product.
operationId: getAccessAdminAudit
security:
- userSessionAuth: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 500
default: 50
- name: product
in: query
required: false
schema:
type: string
responses:
'200':
description: Audit entries
content:
application/json:
schema:
$ref: '#/components/schemas/AccessAuditResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
description: Database not available
/api/v1/access/internal/validate-key:
get:
tags:
- Access
summary: Validate an API key for nginx auth_request or similar edge subrequests
description: >-
Requires `X-Access-Internal-Secret` and accepts the presented API key in
`X-API-Key` or `Authorization: Bearer ...`. Returns `200` or `401` and
emits validation metadata in response headers.
operationId: validateAccessApiKeyInternalGet
parameters:
- name: X-Access-Internal-Secret
in: header
required: true
schema:
type: string
- name: X-API-Key
in: header
required: false
schema:
type: string
- name: Authorization
in: header
required: false
schema:
type: string
- name: X-Access-Method
in: header
required: false
schema:
type: string
- name: X-Access-Request-Count
in: header
required: false
schema:
type: integer
responses:
'200':
description: Key validated
headers:
X-Validated-Product:
schema:
type: string
X-Validated-Tier:
schema:
type: string
X-Validated-Scopes:
schema:
type: string
X-Quota-Remaining:
schema:
type: string
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
post:
tags:
- Access
summary: Validate an API key for internal edge enforcement
description: Requires `X-Access-Internal-Secret` and returns validated key metadata while incrementing usage counters.
operationId: validateAccessApiKeyInternal
parameters:
- name: X-Access-Internal-Secret
in: header
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/InternalValidateAPIKeyRequest'
responses:
'200':
description: Key validated
content:
application/json:
schema:
$ref: '#/components/schemas/InternalValidateAPIKeyResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/blocks:
get:
tags:
@@ -272,7 +812,7 @@ paths:
'400':
$ref: '#/components/responses/BadRequest'
'503':
description: `TOKEN_AGGREGATION_BASE_URL` not configured
description: "`TOKEN_AGGREGATION_BASE_URL` not configured"
/api/v1/mission-control/bridge/trace:
get:
@@ -317,7 +857,7 @@ paths:
properties:
script:
type: string
description: Path relative to `OPERATOR_SCRIPTS_ROOT`
description: "Path relative to `OPERATOR_SCRIPTS_ROOT`"
args:
type: array
items:
@@ -363,8 +903,413 @@ components:
scheme: bearer
bearerFormat: JWT
description: JWT token obtained from /api/v1/auth/wallet
userSessionAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: User session token obtained from /api/v1/auth/register or /api/v1/auth/login
schemas:
WalletNonceRequest:
type: object
required: [address]
properties:
address:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
WalletNonceResponse:
type: object
properties:
address:
type: string
nonce:
type: string
message:
type: string
WalletAuthRequest:
type: object
required: [address, signature, nonce]
properties:
address:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
signature:
type: string
nonce:
type: string
WalletAuthResponse:
type: object
properties:
token:
type: string
expires_at:
type: string
format: date-time
user:
type: object
additionalProperties: true
User:
type: object
properties:
id:
type: string
email:
type: string
format: email
username:
type: string
is_admin:
type: boolean
UserRegisterRequest:
type: object
required: [email, username, password]
properties:
email:
type: string
format: email
username:
type: string
password:
type: string
minLength: 8
UserLoginRequest:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
UserSessionResponse:
type: object
properties:
user:
$ref: '#/components/schemas/User'
token:
type: string
expires_at:
type: string
format: date-time
AccessProduct:
type: object
properties:
slug:
type: string
name:
type: string
provider:
type: string
vmid:
type: integer
http_url:
type: string
ws_url:
type: string
default_tier:
type: string
requires_approval:
type: boolean
billing_model:
type: string
description:
type: string
use_cases:
type: array
items:
type: string
management_features:
type: array
items:
type: string
AccessProductsResponse:
type: object
properties:
products:
type: array
items:
$ref: '#/components/schemas/AccessProduct'
note:
type: string
AccessAPIKeyRecord:
type: object
properties:
id:
type: string
name:
type: string
tier:
type: string
productSlug:
type: string
scopes:
type: array
items:
type: string
monthlyQuota:
type: integer
requestsUsed:
type: integer
approved:
type: boolean
approvedAt:
type: string
format: date-time
nullable: true
rateLimitPerSecond:
type: integer
rateLimitPerMinute:
type: integer
lastUsedAt:
type: string
format: date-time
nullable: true
expiresAt:
type: string
format: date-time
nullable: true
revoked:
type: boolean
createdAt:
type: string
format: date-time
AccessSubscription:
type: object
properties:
id:
type: string
productSlug:
type: string
tier:
type: string
status:
type: string
enum: [active, pending, suspended, revoked]
monthlyQuota:
type: integer
requestsUsed:
type: integer
requiresApproval:
type: boolean
approvedAt:
type: string
format: date-time
nullable: true
approvedBy:
type: string
nullable: true
notes:
type: string
nullable: true
createdAt:
type: string
format: date-time
AccessUsageSummary:
type: object
properties:
product_slug:
type: string
active_keys:
type: integer
requests_used:
type: integer
monthly_quota:
type: integer
AccessMeResponse:
type: object
properties:
user:
$ref: '#/components/schemas/User'
subscriptions:
type: array
items:
$ref: '#/components/schemas/AccessSubscription'
AccessSubscriptionsResponse:
type: object
properties:
subscriptions:
type: array
items:
$ref: '#/components/schemas/AccessSubscription'
AccessSubscriptionResponse:
type: object
properties:
subscription:
$ref: '#/components/schemas/AccessSubscription'
AccessAPIKeysResponse:
type: object
properties:
api_keys:
type: array
items:
$ref: '#/components/schemas/AccessAPIKeyRecord'
CreateSubscriptionRequest:
type: object
required: [product_slug]
properties:
product_slug:
type: string
tier:
type: string
CreateAPIKeyRequest:
type: object
required: [name]
properties:
name:
type: string
tier:
type: string
product_slug:
type: string
expires_days:
type: integer
monthly_quota:
type: integer
scopes:
type: array
items:
type: string
AdminSubscriptionActionRequest:
type: object
required: [subscription_id, status]
properties:
subscription_id:
type: string
status:
type: string
enum: [active, suspended, revoked]
notes:
type: string
CreateAPIKeyResponse:
type: object
properties:
api_key:
type: string
description: Plaintext key is only returned at creation time.
record:
$ref: '#/components/schemas/AccessAPIKeyRecord'
RevokeAPIKeyResponse:
type: object
properties:
revoked:
type: boolean
api_key_id:
type: string
AccessUsageResponse:
type: object
properties:
usage:
type: array
items:
$ref: '#/components/schemas/AccessUsageSummary'
AccessAuditEntry:
type: object
properties:
id:
type: integer
apiKeyId:
type: string
keyName:
type: string
productSlug:
type: string
methodName:
type: string
requestCount:
type: integer
lastIp:
type: string
nullable: true
createdAt:
type: string
format: date-time
AccessAuditResponse:
type: object
properties:
entries:
type: array
items:
$ref: '#/components/schemas/AccessAuditEntry'
InternalValidatedAPIKey:
type: object
properties:
apiKeyId:
type: string
userId:
type: string
name:
type: string
tier:
type: string
productSlug:
type: string
scopes:
type: array
items:
type: string
monthlyQuota:
type: integer
requestsUsed:
type: integer
rateLimitPerSecond:
type: integer
rateLimitPerMinute:
type: integer
lastUsedAt:
type: string
format: date-time
nullable: true
expiresAt:
type: string
format: date-time
nullable: true
InternalValidateAPIKeyRequest:
type: object
required: [api_key]
properties:
api_key:
type: string
method_name:
type: string
request_count:
type: integer
last_ip:
type: string
InternalValidateAPIKeyResponse:
type: object
properties:
valid:
type: boolean
key:
$ref: '#/components/schemas/InternalValidatedAPIKey'
Block:
type: object
properties:

View File

@@ -22,6 +22,10 @@ func (s *Server) HandleMissionControlStream(w http.ResponseWriter, r *http.Reque
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
// Immediate event so nginx unbuffers and short curl probes see `event:`/`data:` before RPC probes finish.
_, _ = fmt.Fprintf(w, ": mission-control stream\n\nevent: ping\ndata: {}\n\n")
_ = controller.Flush()
tick := time.NewTicker(20 * time.Second)
defer tick.Stop()

View File

@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
@@ -19,6 +20,45 @@ type runScriptRequest struct {
Args []string `json:"args"`
}
const maxOperatorScriptOutputBytes = 64 << 10
type cappedBuffer struct {
buf bytes.Buffer
maxBytes int
truncated bool
}
func (c *cappedBuffer) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
remaining := c.maxBytes - c.buf.Len()
if remaining > 0 {
if len(p) > remaining {
_, _ = c.buf.Write(p[:remaining])
c.truncated = true
return len(p), nil
}
_, _ = c.buf.Write(p)
return len(p), nil
}
c.truncated = true
return len(p), nil
}
func (c *cappedBuffer) String() string {
if !c.truncated {
return c.buf.String()
}
return fmt.Sprintf("%s\n[truncated after %d bytes]", c.buf.String(), c.maxBytes)
}
func (c *cappedBuffer) Len() int {
return c.buf.Len()
}
// HandleRunScript handles POST /api/v1/track4/operator/run-script
// Requires Track 4 auth, IP whitelist, OPERATOR_SCRIPTS_ROOT, and OPERATOR_SCRIPT_ALLOWLIST.
func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
@@ -96,10 +136,11 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
}
relPath, _ := filepath.Rel(rootAbs, candidate)
relPath = filepath.Clean(filepath.ToSlash(relPath))
allowed := false
base := filepath.Base(relPath)
for _, a := range allow {
if a == relPath || a == base || filepath.Clean(a) == relPath {
normalizedAllow := filepath.Clean(filepath.ToSlash(a))
if normalizedAllow == relPath {
allowed = true
break
}
@@ -143,7 +184,9 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
} else {
cmd = exec.CommandContext(ctx, candidate, reqBody.Args...)
}
var stdout, stderr bytes.Buffer
var stdout, stderr cappedBuffer
stdout.maxBytes = maxOperatorScriptOutputBytes
stderr.maxBytes = maxOperatorScriptOutputBytes
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
@@ -176,15 +219,19 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
"timed_out": timedOut,
"stdout_bytes": stdout.Len(),
"stderr_bytes": stderr.Len(),
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
}, ipAddr, r.UserAgent())
resp := map[string]interface{}{
"data": map[string]interface{}{
"script": relPath,
"exit_code": exit,
"stdout": strings.TrimSpace(stdout.String()),
"stderr": strings.TrimSpace(stderr.String()),
"timed_out": timedOut,
"script": relPath,
"exit_code": exit,
"stdout": strings.TrimSpace(stdout.String()),
"stderr": strings.TrimSpace(stderr.String()),
"timed_out": timedOut,
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
},
}
w.Header().Set("Content-Type", "application/json")

View File

@@ -86,3 +86,60 @@ func TestHandleRunScriptRejectsNonAllowlistedScript(t *testing.T) {
require.Equal(t, http.StatusForbidden, w.Code)
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
}
func TestHandleRunScriptRejectsFilenameCollisionOutsideAllowlistedPath(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "safe"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(root, "unsafe"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "safe", "backup.sh"), []byte("#!/usr/bin/env bash\necho safe\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "unsafe", "backup.sh"), []byte("#!/usr/bin/env bash\necho unsafe\n"), 0o644))
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "safe/backup.sh")
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"unsafe/backup.sh"}`)))
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
req.RemoteAddr = "127.0.0.1:9999"
w := httptest.NewRecorder()
s.HandleRunScript(w, req)
require.Equal(t, http.StatusForbidden, w.Code)
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
}
func TestHandleRunScriptTruncatesLargeOutput(t *testing.T) {
root := t.TempDir()
scriptPath := filepath.Join(root, "large.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\npython3 - <<'PY'\nprint('x' * 70000)\nPY\n"), 0o644))
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "large.sh")
t.Setenv("OPERATOR_SCRIPT_TIMEOUT_SEC", "30")
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"large.sh"}`)))
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
req.RemoteAddr = "127.0.0.1:9999"
w := httptest.NewRecorder()
s.HandleRunScript(w, req)
require.Equal(t, http.StatusOK, w.Code)
var out struct {
Data struct {
ExitCode float64 `json:"exit_code"`
Stdout string `json:"stdout"`
StdoutTruncated bool `json:"stdout_truncated"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
require.Equal(t, float64(0), out.Data.ExitCode)
require.True(t, out.Data.StdoutTruncated)
require.Contains(t, out.Data.Stdout, "[truncated after")
require.LessOrEqual(t, len(out.Data.Stdout), maxOperatorScriptOutputBytes+64)
}