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

@@ -1,7 +1,7 @@
# Testing Guide
## Backend API Testing Documentation
This document describes the testing infrastructure for the SolaceScanScout backend.
This document describes the testing infrastructure for the SolaceScan backend.
---
@@ -226,4 +226,3 @@ jobs:
---
**Last Updated**: $(date)

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)
}

View File

@@ -30,6 +30,155 @@ type User struct {
CreatedAt time.Time
}
type APIKeyInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Tier string `json:"tier"`
ProductSlug string `json:"productSlug"`
Scopes []string `json:"scopes"`
MonthlyQuota int `json:"monthlyQuota"`
RequestsUsed int `json:"requestsUsed"`
Approved bool `json:"approved"`
ApprovedAt *time.Time `json:"approvedAt"`
RateLimitPerSecond int `json:"rateLimitPerSecond"`
RateLimitPerMinute int `json:"rateLimitPerMinute"`
LastUsedAt *time.Time `json:"lastUsedAt"`
ExpiresAt *time.Time `json:"expiresAt"`
Revoked bool `json:"revoked"`
CreatedAt time.Time `json:"createdAt"`
}
type ValidatedAPIKey struct {
UserID string `json:"userId"`
APIKeyID string `json:"apiKeyId"`
Name string `json:"name"`
Tier string `json:"tier"`
ProductSlug string `json:"productSlug"`
Scopes []string `json:"scopes"`
MonthlyQuota int `json:"monthlyQuota"`
RequestsUsed int `json:"requestsUsed"`
RateLimitPerSecond int `json:"rateLimitPerSecond"`
RateLimitPerMinute int `json:"rateLimitPerMinute"`
LastUsedAt *time.Time `json:"lastUsedAt"`
ExpiresAt *time.Time `json:"expiresAt"`
}
type ProductSubscription struct {
ID string `json:"id"`
ProductSlug string `json:"productSlug"`
Tier string `json:"tier"`
Status string `json:"status"`
MonthlyQuota int `json:"monthlyQuota"`
RequestsUsed int `json:"requestsUsed"`
RequiresApproval bool `json:"requiresApproval"`
ApprovedAt *time.Time `json:"approvedAt"`
ApprovedBy *string `json:"approvedBy"`
Notes *string `json:"notes"`
CreatedAt time.Time `json:"createdAt"`
}
type APIKeyUsageLog struct {
ID int64 `json:"id"`
APIKeyID string `json:"apiKeyId"`
KeyName string `json:"keyName"`
ProductSlug string `json:"productSlug"`
MethodName string `json:"methodName"`
RequestCount int `json:"requestCount"`
LastIP *string `json:"lastIp"`
CreatedAt time.Time `json:"createdAt"`
}
func (a *Auth) ListAllSubscriptions(ctx context.Context, status string) ([]ProductSubscription, error) {
query := `
SELECT id, product_slug, tier, status, COALESCE(monthly_quota, 0), COALESCE(requests_used, 0),
requires_approval, approved_at, approved_by, notes, created_at
FROM user_product_subscriptions
`
args := []any{}
if status != "" {
query += ` WHERE status = $1`
args = append(args, status)
}
query += ` ORDER BY created_at DESC`
rows, err := a.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list all subscriptions: %w", err)
}
defer rows.Close()
subs := make([]ProductSubscription, 0)
for rows.Next() {
var sub ProductSubscription
var approvedAt *time.Time
var approvedBy, notes *string
if err := rows.Scan(
&sub.ID,
&sub.ProductSlug,
&sub.Tier,
&sub.Status,
&sub.MonthlyQuota,
&sub.RequestsUsed,
&sub.RequiresApproval,
&approvedAt,
&approvedBy,
&notes,
&sub.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan subscription: %w", err)
}
sub.ApprovedAt = approvedAt
sub.ApprovedBy = approvedBy
sub.Notes = notes
subs = append(subs, sub)
}
return subs, nil
}
func (a *Auth) UpdateSubscriptionStatus(
ctx context.Context,
subscriptionID string,
status string,
approvedBy string,
notes string,
) (*ProductSubscription, error) {
query := `
UPDATE user_product_subscriptions
SET status = $2,
approved_at = CASE WHEN $2 = 'active' THEN NOW() ELSE approved_at END,
approved_by = CASE WHEN $2 = 'active' THEN NULLIF($3, '') ELSE approved_by END,
notes = CASE WHEN NULLIF($4, '') IS NOT NULL THEN $4 ELSE notes END,
updated_at = NOW()
WHERE id = $1
RETURNING id, product_slug, tier, status, COALESCE(monthly_quota, 0), COALESCE(requests_used, 0),
requires_approval, approved_at, approved_by, notes, created_at
`
var sub ProductSubscription
var approvedAt *time.Time
var approvedByPtr, notesPtr *string
if err := a.db.QueryRow(ctx, query, subscriptionID, status, approvedBy, notes).Scan(
&sub.ID,
&sub.ProductSlug,
&sub.Tier,
&sub.Status,
&sub.MonthlyQuota,
&sub.RequestsUsed,
&sub.RequiresApproval,
&approvedAt,
&approvedByPtr,
&notesPtr,
&sub.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to update subscription: %w", err)
}
sub.ApprovedAt = approvedAt
sub.ApprovedBy = approvedByPtr
sub.Notes = notesPtr
return &sub, nil
}
// RegisterUser registers a new user
func (a *Auth) RegisterUser(ctx context.Context, email, username, password string) (*User, error) {
// Hash password
@@ -76,11 +225,17 @@ func (a *Auth) AuthenticateUser(ctx context.Context, email, password string) (*U
return nil, fmt.Errorf("invalid credentials")
}
_, _ = a.db.Exec(ctx, `UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1`, user.ID)
return &user, nil
}
// GenerateAPIKey generates a new API key for a user
func (a *Auth) GenerateAPIKey(ctx context.Context, userID, name string, tier string) (string, error) {
return a.GenerateScopedAPIKey(ctx, userID, name, tier, "", nil, 0, false, 0)
}
func (a *Auth) GenerateScopedAPIKey(ctx context.Context, userID, name string, tier string, productSlug string, scopes []string, monthlyQuota int, approved bool, expiresDays int) (string, error) {
// Generate random key
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
@@ -110,13 +265,22 @@ func (a *Auth) GenerateAPIKey(ctx context.Context, userID, name string, tier str
rateLimitPerMinute = 100
}
var expiresAt *time.Time
if expiresDays > 0 {
expires := time.Now().Add(time.Duration(expiresDays) * 24 * time.Hour)
expiresAt = &expires
}
// Store API key
query := `
INSERT INTO api_keys (user_id, key_hash, name, tier, rate_limit_per_second, rate_limit_per_minute)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO api_keys (
user_id, key_hash, name, tier, product_slug, scopes, monthly_quota,
rate_limit_per_second, rate_limit_per_minute, approved, approved_at, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CASE WHEN $10 THEN NOW() ELSE NULL END, $11)
`
_, err := a.db.Exec(ctx, query, userID, hashedKeyHex, name, tier, rateLimitPerSecond, rateLimitPerMinute)
_, err := a.db.Exec(ctx, query, userID, hashedKeyHex, name, tier, productSlug, scopes, monthlyQuota, rateLimitPerSecond, rateLimitPerMinute, approved, expiresAt)
if err != nil {
return "", fmt.Errorf("failed to store API key: %w", err)
}
@@ -130,9 +294,10 @@ func (a *Auth) ValidateAPIKey(ctx context.Context, apiKey string) (string, error
hashedKeyHex := hex.EncodeToString(hashedKey[:])
var userID string
var revoked bool
query := `SELECT user_id, revoked FROM api_keys WHERE key_hash = $1`
err := a.db.QueryRow(ctx, query, hashedKeyHex).Scan(&userID, &revoked)
var revoked, approved bool
var expiresAt *time.Time
query := `SELECT user_id, revoked, approved, expires_at FROM api_keys WHERE key_hash = $1`
err := a.db.QueryRow(ctx, query, hashedKeyHex).Scan(&userID, &revoked, &approved, &expiresAt)
if err != nil {
return "", fmt.Errorf("invalid API key")
@@ -141,6 +306,12 @@ func (a *Auth) ValidateAPIKey(ctx context.Context, apiKey string) (string, error
if revoked {
return "", fmt.Errorf("API key revoked")
}
if !approved {
return "", fmt.Errorf("API key pending approval")
}
if expiresAt != nil && time.Now().After(*expiresAt) {
return "", fmt.Errorf("API key expired")
}
// Update last used
a.db.Exec(ctx, `UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1`, hashedKeyHex)
@@ -148,3 +319,313 @@ func (a *Auth) ValidateAPIKey(ctx context.Context, apiKey string) (string, error
return userID, nil
}
func (a *Auth) ValidateAPIKeyDetailed(ctx context.Context, apiKey string, methodName string, requestCount int, lastIPAddress string) (*ValidatedAPIKey, error) {
hashedKey := sha256.Sum256([]byte(apiKey))
hashedKeyHex := hex.EncodeToString(hashedKey[:])
query := `
SELECT id, user_id, COALESCE(name, ''), tier, COALESCE(product_slug, ''), COALESCE(scopes, ARRAY[]::TEXT[]),
COALESCE(monthly_quota, 0), COALESCE(requests_used, 0), approved,
COALESCE(rate_limit_per_second, 0), COALESCE(rate_limit_per_minute, 0),
last_used_at, expires_at, revoked
FROM api_keys
WHERE key_hash = $1
`
var validated ValidatedAPIKey
var approved, revoked bool
var lastUsedAt, expiresAt *time.Time
if err := a.db.QueryRow(ctx, query, hashedKeyHex).Scan(
&validated.APIKeyID,
&validated.UserID,
&validated.Name,
&validated.Tier,
&validated.ProductSlug,
&validated.Scopes,
&validated.MonthlyQuota,
&validated.RequestsUsed,
&approved,
&validated.RateLimitPerSecond,
&validated.RateLimitPerMinute,
&lastUsedAt,
&expiresAt,
&revoked,
); err != nil {
return nil, fmt.Errorf("invalid API key")
}
if revoked {
return nil, fmt.Errorf("API key revoked")
}
if !approved {
return nil, fmt.Errorf("API key pending approval")
}
if expiresAt != nil && time.Now().After(*expiresAt) {
return nil, fmt.Errorf("API key expired")
}
if requestCount <= 0 {
requestCount = 1
}
_, _ = a.db.Exec(ctx, `
UPDATE api_keys
SET last_used_at = NOW(),
requests_used = COALESCE(requests_used, 0) + $2,
last_ip_address = NULLIF($3, '')::inet
WHERE key_hash = $1
`, hashedKeyHex, requestCount, lastIPAddress)
_, _ = a.db.Exec(ctx, `
INSERT INTO api_key_usage_logs (api_key_id, product_slug, method_name, request_count, window_start, window_end, last_ip_address)
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, NOW(), NOW(), NULLIF($5, '')::inet)
`, validated.APIKeyID, validated.ProductSlug, methodName, requestCount, lastIPAddress)
validated.RequestsUsed += requestCount
validated.LastUsedAt = lastUsedAt
validated.ExpiresAt = expiresAt
return &validated, nil
}
func (a *Auth) ListAPIKeys(ctx context.Context, userID string) ([]APIKeyInfo, error) {
rows, err := a.db.Query(ctx, `
SELECT id, COALESCE(name, ''), tier, COALESCE(product_slug, ''), COALESCE(scopes, ARRAY[]::TEXT[]),
COALESCE(monthly_quota, 0), COALESCE(requests_used, 0), approved, approved_at,
COALESCE(rate_limit_per_second, 0), COALESCE(rate_limit_per_minute, 0),
last_used_at, expires_at, revoked, created_at
FROM api_keys
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("failed to list API keys: %w", err)
}
defer rows.Close()
keys := make([]APIKeyInfo, 0)
for rows.Next() {
var key APIKeyInfo
var lastUsedAt, expiresAt, approvedAt *time.Time
if err := rows.Scan(
&key.ID,
&key.Name,
&key.Tier,
&key.ProductSlug,
&key.Scopes,
&key.MonthlyQuota,
&key.RequestsUsed,
&key.Approved,
&approvedAt,
&key.RateLimitPerSecond,
&key.RateLimitPerMinute,
&lastUsedAt,
&expiresAt,
&key.Revoked,
&key.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan API key: %w", err)
}
key.ApprovedAt = approvedAt
key.LastUsedAt = lastUsedAt
key.ExpiresAt = expiresAt
keys = append(keys, key)
}
return keys, nil
}
func (a *Auth) ListUsageLogs(ctx context.Context, userID string, limit int) ([]APIKeyUsageLog, error) {
if limit <= 0 {
limit = 20
}
rows, err := a.db.Query(ctx, `
SELECT logs.id, logs.api_key_id, COALESCE(keys.name, ''), COALESCE(logs.product_slug, ''),
COALESCE(logs.method_name, ''), logs.request_count,
CASE WHEN logs.last_ip_address IS NOT NULL THEN host(logs.last_ip_address) ELSE NULL END,
logs.created_at
FROM api_key_usage_logs logs
INNER JOIN api_keys keys ON keys.id = logs.api_key_id
WHERE keys.user_id = $1
ORDER BY logs.created_at DESC
LIMIT $2
`, userID, limit)
if err != nil {
return nil, fmt.Errorf("failed to list usage logs: %w", err)
}
defer rows.Close()
entries := make([]APIKeyUsageLog, 0)
for rows.Next() {
var entry APIKeyUsageLog
var lastIP *string
if err := rows.Scan(
&entry.ID,
&entry.APIKeyID,
&entry.KeyName,
&entry.ProductSlug,
&entry.MethodName,
&entry.RequestCount,
&lastIP,
&entry.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan usage log: %w", err)
}
entry.LastIP = lastIP
entries = append(entries, entry)
}
return entries, nil
}
func (a *Auth) ListAllUsageLogs(ctx context.Context, productSlug string, limit int) ([]APIKeyUsageLog, error) {
if limit <= 0 {
limit = 50
}
query := `
SELECT logs.id, logs.api_key_id, COALESCE(keys.name, ''), COALESCE(logs.product_slug, ''),
COALESCE(logs.method_name, ''), logs.request_count,
CASE WHEN logs.last_ip_address IS NOT NULL THEN host(logs.last_ip_address) ELSE NULL END,
logs.created_at
FROM api_key_usage_logs logs
INNER JOIN api_keys keys ON keys.id = logs.api_key_id
`
args := []any{}
if productSlug != "" {
query += ` WHERE logs.product_slug = $1`
args = append(args, productSlug)
}
query += fmt.Sprintf(" ORDER BY logs.created_at DESC LIMIT $%d", len(args)+1)
args = append(args, limit)
rows, err := a.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list all usage logs: %w", err)
}
defer rows.Close()
entries := make([]APIKeyUsageLog, 0)
for rows.Next() {
var entry APIKeyUsageLog
var lastIP *string
if err := rows.Scan(
&entry.ID,
&entry.APIKeyID,
&entry.KeyName,
&entry.ProductSlug,
&entry.MethodName,
&entry.RequestCount,
&lastIP,
&entry.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan usage log: %w", err)
}
entry.LastIP = lastIP
entries = append(entries, entry)
}
return entries, nil
}
func (a *Auth) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
tag, err := a.db.Exec(ctx, `UPDATE api_keys SET revoked = true WHERE id = $1 AND user_id = $2`, keyID, userID)
if err != nil {
return fmt.Errorf("failed to revoke API key: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("api key not found")
}
return nil
}
func (a *Auth) UpsertProductSubscription(
ctx context.Context,
userID, productSlug, tier, status string,
monthlyQuota int,
requiresApproval bool,
approvedBy string,
notes string,
) (*ProductSubscription, error) {
query := `
INSERT INTO user_product_subscriptions (
user_id, product_slug, tier, status, monthly_quota, requires_approval, approved_at, approved_by, notes
)
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $4 = 'active' THEN NOW() ELSE NULL END, NULLIF($7, ''), NULLIF($8, ''))
ON CONFLICT (user_id, product_slug) DO UPDATE SET
tier = EXCLUDED.tier,
status = EXCLUDED.status,
monthly_quota = EXCLUDED.monthly_quota,
requires_approval = EXCLUDED.requires_approval,
approved_at = CASE WHEN EXCLUDED.status = 'active' THEN NOW() ELSE user_product_subscriptions.approved_at END,
approved_by = NULLIF(EXCLUDED.approved_by, ''),
notes = NULLIF(EXCLUDED.notes, ''),
updated_at = NOW()
RETURNING id, product_slug, tier, status, COALESCE(monthly_quota, 0), COALESCE(requests_used, 0),
requires_approval, approved_at, approved_by, notes, created_at
`
var sub ProductSubscription
var approvedAt *time.Time
var approvedByPtr, notesPtr *string
if err := a.db.QueryRow(ctx, query, userID, productSlug, tier, status, monthlyQuota, requiresApproval, approvedBy, notes).Scan(
&sub.ID,
&sub.ProductSlug,
&sub.Tier,
&sub.Status,
&sub.MonthlyQuota,
&sub.RequestsUsed,
&sub.RequiresApproval,
&approvedAt,
&approvedByPtr,
&notesPtr,
&sub.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to save subscription: %w", err)
}
sub.ApprovedAt = approvedAt
sub.ApprovedBy = approvedByPtr
sub.Notes = notesPtr
return &sub, nil
}
func (a *Auth) ListSubscriptions(ctx context.Context, userID string) ([]ProductSubscription, error) {
rows, err := a.db.Query(ctx, `
SELECT id, product_slug, tier, status, COALESCE(monthly_quota, 0), COALESCE(requests_used, 0),
requires_approval, approved_at, approved_by, notes, created_at
FROM user_product_subscriptions
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("failed to list subscriptions: %w", err)
}
defer rows.Close()
subs := make([]ProductSubscription, 0)
for rows.Next() {
var sub ProductSubscription
var approvedAt *time.Time
var approvedBy, notes *string
if err := rows.Scan(
&sub.ID,
&sub.ProductSlug,
&sub.Tier,
&sub.Status,
&sub.MonthlyQuota,
&sub.RequestsUsed,
&sub.RequiresApproval,
&approvedAt,
&approvedBy,
&notes,
&sub.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan subscription: %w", err)
}
sub.ApprovedAt = approvedAt
sub.ApprovedBy = approvedBy
sub.Notes = notes
subs = append(subs, sub)
}
return subs, nil
}

View File

@@ -141,7 +141,7 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
}
// Verify signature
message := fmt.Sprintf("Sign this message to authenticate with SolaceScanScout Explorer.\n\nNonce: %s", req.Nonce)
message := fmt.Sprintf("Sign this message to authenticate with SolaceScan.\n\nNonce: %s", req.Nonce)
messageHash := accounts.TextHash([]byte(message))
sigBytes, err := decodeWalletSignature(req.Signature)

View File

@@ -0,0 +1,13 @@
DROP TABLE IF EXISTS api_key_usage_logs;
DROP TABLE IF EXISTS user_product_subscriptions;
DROP TABLE IF EXISTS rpc_products;
ALTER TABLE api_keys
DROP COLUMN IF EXISTS product_slug,
DROP COLUMN IF EXISTS scopes,
DROP COLUMN IF EXISTS monthly_quota,
DROP COLUMN IF EXISTS requests_used,
DROP COLUMN IF EXISTS approved,
DROP COLUMN IF EXISTS approved_at,
DROP COLUMN IF EXISTS approved_by,
DROP COLUMN IF EXISTS last_ip_address;

View File

@@ -0,0 +1,79 @@
-- Migration: Access Management Schema
-- Description: Adds RPC product subscriptions, richer API key metadata, and usage logging.
ALTER TABLE api_keys
ADD COLUMN IF NOT EXISTS product_slug VARCHAR(100),
ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS monthly_quota INTEGER,
ADD COLUMN IF NOT EXISTS requests_used INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS approved BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(255),
ADD COLUMN IF NOT EXISTS last_ip_address INET;
CREATE TABLE IF NOT EXISTS rpc_products (
slug VARCHAR(100) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
provider VARCHAR(100) NOT NULL,
vmid INTEGER NOT NULL,
http_url TEXT NOT NULL,
ws_url TEXT,
default_tier VARCHAR(20) NOT NULL,
requires_approval BOOLEAN NOT NULL DEFAULT false,
billing_model VARCHAR(50) NOT NULL DEFAULT 'subscription',
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_product_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_slug VARCHAR(100) NOT NULL REFERENCES rpc_products(slug) ON DELETE CASCADE,
tier VARCHAR(20) NOT NULL CHECK (tier IN ('free', 'pro', 'enterprise')),
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'suspended', 'revoked')),
monthly_quota INTEGER,
requests_used INTEGER NOT NULL DEFAULT 0,
requires_approval BOOLEAN NOT NULL DEFAULT false,
approved_at TIMESTAMP,
approved_by VARCHAR(255),
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, product_slug)
);
CREATE TABLE IF NOT EXISTS api_key_usage_logs (
id BIGSERIAL PRIMARY KEY,
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
product_slug VARCHAR(100),
method_name VARCHAR(100),
request_count INTEGER NOT NULL DEFAULT 1,
window_start TIMESTAMP NOT NULL DEFAULT NOW(),
window_end TIMESTAMP,
last_ip_address INET,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_product_subscriptions_user ON user_product_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_product_subscriptions_product ON user_product_subscriptions(product_slug);
CREATE INDEX IF NOT EXISTS idx_user_product_subscriptions_status ON user_product_subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_api_key_usage_logs_key ON api_key_usage_logs(api_key_id);
CREATE INDEX IF NOT EXISTS idx_api_key_usage_logs_product ON api_key_usage_logs(product_slug);
INSERT INTO rpc_products (slug, name, provider, vmid, http_url, ws_url, default_tier, requires_approval, billing_model, description)
VALUES
('core-rpc', 'Core RPC', 'besu-core', 2101, 'https://rpc-http-prv.d-bis.org', 'wss://rpc-ws-prv.d-bis.org', 'enterprise', true, 'contract', 'Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.'),
('alltra-rpc', 'Alltra RPC', 'alltra', 2102, 'http://192.168.11.212:8545', 'ws://192.168.11.212:8546', 'pro', false, 'subscription', 'Dedicated Alltra RPC lane for partner traffic, subscription access, and API-key-gated usage.'),
('thirdweb-rpc', 'Thirdweb RPC', 'thirdweb', 2103, 'http://192.168.11.217:8545', 'ws://192.168.11.217:8546', 'pro', false, 'subscription', 'Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.')
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
provider = EXCLUDED.provider,
vmid = EXCLUDED.vmid,
http_url = EXCLUDED.http_url,
ws_url = EXCLUDED.ws_url,
default_tier = EXCLUDED.default_tier,
requires_approval = EXCLUDED.requires_approval,
billing_model = EXCLUDED.billing_model,
description = EXCLUDED.description,
updated_at = NOW();