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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user