diff --git a/EXECUTE_DEPLOYMENT.sh b/EXECUTE_DEPLOYMENT.sh index 90940ed..cc92842 100644 --- a/EXECUTE_DEPLOYMENT.sh +++ b/EXECUTE_DEPLOYMENT.sh @@ -5,7 +5,7 @@ set -e echo "==========================================" -echo " SolaceScanScout Deployment" +echo " SolaceScan Deployment" echo "==========================================" echo "" @@ -140,4 +140,3 @@ echo " 3. Monitor: tail -f backend/logs/api-server.log" echo "" unset PGPASSWORD - diff --git a/README.md b/README.md index cf42fce..c0f8c71 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SolaceScanScout Explorer - Tiered Architecture +# SolaceScan Explorer - Tiered Architecture ## ๐ Quick Start - Complete Deployment @@ -75,7 +75,7 @@ See [docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md](docs/REUSABLE_COMPONENTS_EXTRA - **All unit/lint:** `make test` โ backend `go test ./...` and frontend `npm test` (lint + type-check). - **Backend:** `cd backend && go test ./...` โ API tests run without a real DB; health returns 200 or 503, DB-dependent endpoints return 503 when DB is nil. - **Frontend:** `cd frontend && npm run build` or `npm test` โ Next.js build (includes lint) or lint + type-check only. -- **E2E:** `make test-e2e` or `npm run e2e` from repo root โ Playwright tests against https://explorer.d-bis.org by default; use `EXPLORER_URL=http://localhost:3000` for local. +- **E2E:** `make test-e2e` or `npm run e2e` from repo root โ Playwright tests against https://blockscout.defi-oracle.io by default; use `EXPLORER_URL=http://localhost:3000` for local. ## Status diff --git a/backend/README_TESTING.md b/backend/README_TESTING.md index 2cd12ed..47a078d 100644 --- a/backend/README_TESTING.md +++ b/backend/README_TESTING.md @@ -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) - diff --git a/backend/api/gateway/gateway.go b/backend/api/gateway/gateway.go index dd9841b..260fa8b 100644 --- a/backend/api/gateway/gateway.go +++ b/backend/api/gateway/gateway.go @@ -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 diff --git a/backend/api/middleware/security.go b/backend/api/middleware/security.go index 921bf36..cb4b5fe 100644 --- a/backend/api/middleware/security.go +++ b/backend/api/middleware/security.go @@ -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") diff --git a/backend/api/rest/README.md b/backend/api/rest/README.md index 8b06ecc..e62160c 100644 --- a/backend/api/rest/README.md +++ b/backend/api/rest/README.md @@ -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 diff --git a/backend/api/rest/ai.go b/backend/api/rest/ai.go index ae38609..8195bdc 100644 --- a/backend/api/rest/ai.go +++ b/backend/api/rest/ai.go @@ -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." } diff --git a/backend/api/rest/api_test.go b/backend/api/rest/api_test.go index 4d95442..4c6773f 100644 --- a/backend/api/rest/api_test.go +++ b/backend/api/rest/api_test.go @@ -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) diff --git a/backend/api/rest/auth.go b/backend/api/rest/auth.go index 0e8af9c..0da89af 100644 --- a/backend/api/rest/auth.go +++ b/backend/api/rest/auth.go @@ -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, + }) +} diff --git a/backend/api/rest/config/metamask/CHAIN138_RPC_CAPABILITIES.json b/backend/api/rest/config/metamask/CHAIN138_RPC_CAPABILITIES.json index 8927d95..229e601 100644 --- a/backend/api/rest/config/metamask/CHAIN138_RPC_CAPABILITIES.json +++ b/backend/api/rest/config/metamask/CHAIN138_RPC_CAPABILITIES.json @@ -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", diff --git a/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json b/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json index 60e9c04..3799fc3 100644 --- a/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json +++ b/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json @@ -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"]}, diff --git a/backend/api/rest/routes.go b/backend/api/rest/routes.go index a8b496d..86cbcc9 100644 --- a/backend/api/rest/routes.go +++ b/backend/api/rest/routes.go @@ -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 diff --git a/backend/api/rest/server.go b/backend/api/rest/server.go index ea932cd..ac72365 100644 --- a/backend/api/rest/server.go +++ b/backend/api/rest/server.go @@ -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", }, } diff --git a/backend/api/rest/swagger.yaml b/backend/api/rest/swagger.yaml index de94aa1..e89f49d 100644 --- a/backend/api/rest/swagger.yaml +++ b/backend/api/rest/swagger.yaml @@ -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: diff --git a/backend/api/track1/mission_control_sse.go b/backend/api/track1/mission_control_sse.go index def025e..a397403 100644 --- a/backend/api/track1/mission_control_sse.go +++ b/backend/api/track1/mission_control_sse.go @@ -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() diff --git a/backend/api/track4/operator_scripts.go b/backend/api/track4/operator_scripts.go index 4d60931..100d79b 100644 --- a/backend/api/track4/operator_scripts.go +++ b/backend/api/track4/operator_scripts.go @@ -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") diff --git a/backend/api/track4/operator_scripts_test.go b/backend/api/track4/operator_scripts_test.go index d536add..587b98b 100644 --- a/backend/api/track4/operator_scripts_test.go +++ b/backend/api/track4/operator_scripts_test.go @@ -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) +} diff --git a/backend/auth/auth.go b/backend/auth/auth.go index 05dfee8..a0b25d3 100644 --- a/backend/auth/auth.go +++ b/backend/auth/auth.go @@ -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, + ¬es, + &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, + ¬esPtr, + &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, + ¬esPtr, + &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, + ¬es, + &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 +} diff --git a/backend/auth/wallet_auth.go b/backend/auth/wallet_auth.go index 7e96dc2..bd9dae5 100644 --- a/backend/auth/wallet_auth.go +++ b/backend/auth/wallet_auth.go @@ -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) diff --git a/backend/database/migrations/0015_access_management_schema.down.sql b/backend/database/migrations/0015_access_management_schema.down.sql new file mode 100644 index 0000000..fd16e56 --- /dev/null +++ b/backend/database/migrations/0015_access_management_schema.down.sql @@ -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; diff --git a/backend/database/migrations/0015_access_management_schema.up.sql b/backend/database/migrations/0015_access_management_schema.up.sql new file mode 100644 index 0000000..cab3ebf --- /dev/null +++ b/backend/database/migrations/0015_access_management_schema.up.sql @@ -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(); diff --git a/deployment/ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md b/deployment/ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md new file mode 100644 index 0000000..d283c7a --- /dev/null +++ b/deployment/ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md @@ -0,0 +1,171 @@ +# Explorer Access Edge Enforcement Runbook + +Operational runbook for enforcing explorer-issued API keys at the RPC edge for Chain 138 service lanes such as: + +- `alltra-rpc` on VMID `2102` +- `thirdweb-rpc` on VMID `2103` +- approval-gated `core-rpc` on VMID `2101` + +This complements the explorer access console and backend access APIs. The explorer can already issue, rotate, revoke, and validate keys; this runbook covers how to enforce those keys on nginx-facing RPC endpoints. + +## Preconditions + +- Explorer config/API backend is running on VMID `5000` and reachable at `127.0.0.1:8081` +- `ACCESS_INTERNAL_SECRET` is configured on the explorer API service +- Users and subscriptions are already managed through `/access` +- The target RPC lane is behind nginx or another proxy that can make a subrequest to the explorer API + +## Canonical validator endpoint + +- Internal: `http://127.0.0.1:8081/api/v1/access/internal/validate-key` +- Public-prefixed equivalent through explorer nginx: `https://explorer.d-bis.org/explorer-api/v1/access/internal/validate-key` + +### Validator modes + +- `GET` for nginx `auth_request` + - supply `X-API-Key` or `Authorization: Bearer ...` + - supply `X-Access-Internal-Secret` + - returns `200` on success or `401` on rejection + - includes headers such as: + - `X-Validated-Product` + - `X-Validated-Tier` + - `X-Validated-Scopes` + - `X-Quota-Remaining` +- `POST` for richer internal clients + - JSON body with `api_key`, `method_name`, `request_count`, `last_ip` + - returns JSON payload with validated key metadata + +## Canonical nginx pattern + +Use [`common/nginx-rpc-api-key-gate.conf`](./common/nginx-rpc-api-key-gate.conf) as the starting template. +For lane-specific rendered configs, use [`../scripts/render-rpc-access-gate-nginx.sh`](../scripts/render-rpc-access-gate-nginx.sh). + +The important behavior is: + +1. nginx receives user traffic +2. nginx subrequests `/__access_validate_rpc` +3. that subrequest calls the explorer validator with: + - the client API key + - the shared internal secret + - request method and source IP +4. only validated requests are proxied to the protected RPC upstream + +## Render a product-specific config + +Instead of editing the template manually, render a concrete config for the target lane: + +```bash +bash explorer-monorepo/scripts/render-rpc-access-gate-nginx.sh \ + --product thirdweb-rpc \ + --server-name thirdweb-rpc.example.org \ + --internal-secret "$ACCESS_INTERNAL_SECRET" \ + --output /etc/nginx/conf.d/thirdweb-rpc-gated.conf +``` + +Example for `alltra-rpc`: + +```bash +bash explorer-monorepo/scripts/render-rpc-access-gate-nginx.sh \ + --product alltra-rpc \ + --server-name alltra-rpc.example.org \ + --internal-secret "$ACCESS_INTERNAL_SECRET" \ + --output /etc/nginx/conf.d/alltra-rpc-gated.conf +``` + +Example for `core-rpc` with an explicit upstream override: + +```bash +bash explorer-monorepo/scripts/render-rpc-access-gate-nginx.sh \ + --product core-rpc \ + --server-name rpc-http-prv.d-bis.org \ + --internal-secret "$ACCESS_INTERNAL_SECRET" \ + --upstream http://192.168.11.211:8545 \ + --output /etc/nginx/conf.d/core-rpc-gated.conf +``` + +After rendering, verify syntax before reload: + +```bash +nginx -t +systemctl reload nginx +``` + +## Recommended product mapping + +| Product | Suggested public host | Upstream target | +|---|---|---| +| `core-rpc` | `rpc-http-prv.d-bis.org` | `http://192.168.11.211:8545` | +| `alltra-rpc` | partner/internal hostname | `http://192.168.11.212:8545` | +| `thirdweb-rpc` | managed SaaS/internal hostname | `http://192.168.11.217:8545` | + +For `core-rpc`, keep manual approval enabled and consider IP allowlists in addition to API keys. + +## Safe remote install workflow + +For an operator-friendly rollout, use the dry-run-first installer: + +```bash +bash explorer-monorepo/scripts/install-rpc-access-gate-nginx-via-ssh.sh \ + --product thirdweb-rpc \ + --server-name thirdweb-rpc.example.org \ + --ssh-host root@192.168.11.217 \ + --internal-secret "$ACCESS_INTERNAL_SECRET" +``` + +That prints the rendered config and planned remote target without mutating anything. + +Apply only after review: + +```bash +bash explorer-monorepo/scripts/install-rpc-access-gate-nginx-via-ssh.sh \ + --product thirdweb-rpc \ + --server-name thirdweb-rpc.example.org \ + --ssh-host root@192.168.11.217 \ + --internal-secret "$ACCESS_INTERNAL_SECRET" \ + --apply +``` + +By default the installer copies the config, runs `nginx -t`, and only then reloads nginx. + +## Explorer API service env + +At minimum, set: + +```dotenv +ACCESS_ADMIN_EMAILS=ops@example.org,platform@example.org +ACCESS_INTERNAL_SECRET=replace-with-long-random-secret +``` + +## Verification + +Use the dedicated verifier: + +```bash +bash explorer-monorepo/scripts/verify-explorer-access-edge-hook.sh \ + --base-url https://explorer.d-bis.org \ + --internal-secret "$ACCESS_INTERNAL_SECRET" +``` + +To test a real key: + +```bash +bash explorer-monorepo/scripts/verify-explorer-access-edge-hook.sh \ + --base-url https://explorer.d-bis.org \ + --internal-secret "$ACCESS_INTERNAL_SECRET" \ + --api-key "sk_live_example" +``` + +## Rollout order + +1. Deploy explorer config/API backend so the validator endpoint is live +2. Confirm `ACCESS_INTERNAL_SECRET` is loaded in the service env +3. Apply nginx config for one protected lane first, usually `thirdweb-rpc` +4. Verify validation responses and upstream reachability +5. Expand to `alltra-rpc` +6. Apply stricter controls for `core-rpc` only after admin approval flow is tested + +## Honest limits + +- This repo now provides the validator hook, operator docs, and example edge config +- Actual enforcement still depends on where the RPC traffic is terminated +- Billing settlement, Stripe, or x402 monetization is a separate commercial layer diff --git a/deployment/DEPLOYMENT_CHECKLIST.md b/deployment/DEPLOYMENT_CHECKLIST.md index b5b20bf..b20f030 100644 --- a/deployment/DEPLOYMENT_CHECKLIST.md +++ b/deployment/DEPLOYMENT_CHECKLIST.md @@ -54,7 +54,7 @@ Use this checklist to track deployment progress. - [ ] Systemd service files created: - [ ] `explorer-indexer.service` - [ ] `explorer-api.service` - - [ ] `explorer-frontend.service` + - [ ] `solacescanscout-frontend.service` - [ ] Services enabled - [ ] Services started - [ ] Service status verified @@ -201,4 +201,3 @@ _Use this space for deployment-specific notes and issues encountered._ **Deployed By**: _______________ **Container ID**: _______________ **Domain**: explorer.d-bis.org - diff --git a/deployment/DEPLOYMENT_GUIDE.md b/deployment/DEPLOYMENT_GUIDE.md index afb8d3f..9a36dc4 100644 --- a/deployment/DEPLOYMENT_GUIDE.md +++ b/deployment/DEPLOYMENT_GUIDE.md @@ -477,24 +477,26 @@ EOF #### Frontend Service ```bash -cat > /etc/systemd/system/explorer-frontend.service << 'EOF' +cat > /etc/systemd/system/solacescanscout-frontend.service << 'EOF' [Unit] -Description=Explorer Frontend Service +Description=SolaceScan Next Frontend Service After=network.target explorer-api.service Requires=explorer-api.service [Service] Type=simple -User=explorer -Group=explorer -WorkingDirectory=/home/explorer/explorer-monorepo/frontend -EnvironmentFile=/home/explorer/explorer-monorepo/.env -ExecStart=/usr/bin/npm start +User=www-data +Group=www-data +WorkingDirectory=/opt/solacescanscout/frontend/current +Environment=NODE_ENV=production +Environment=HOSTNAME=127.0.0.1 +Environment=PORT=3000 +ExecStart=/usr/bin/node /opt/solacescanscout/frontend/current/server.js Restart=always -RestartSec=10 +RestartSec=5 StandardOutput=journal StandardError=journal -SyslogIdentifier=explorer-frontend +SyslogIdentifier=solacescanscout-frontend [Install] WantedBy=multi-user.target @@ -510,17 +512,17 @@ systemctl daemon-reload # Enable services systemctl enable explorer-indexer systemctl enable explorer-api -systemctl enable explorer-frontend +systemctl enable solacescanscout-frontend # Start services systemctl start explorer-indexer systemctl start explorer-api -systemctl start explorer-frontend +systemctl start solacescanscout-frontend # Check status systemctl status explorer-indexer systemctl status explorer-api -systemctl status explorer-frontend +systemctl status solacescanscout-frontend ``` --- @@ -892,7 +894,7 @@ cat > /etc/logrotate.d/explorer << 'EOF' create 0640 explorer explorer sharedscripts postrotate - systemctl reload explorer-indexer explorer-api explorer-frontend > /dev/null 2>&1 || true + systemctl reload explorer-indexer explorer-api solacescanscout-frontend > /dev/null 2>&1 || true endscript } EOF @@ -1079,4 +1081,3 @@ journalctl -u cloudflared -f **Last Updated**: 2024-12-23 **Version**: 1.0.0 - diff --git a/deployment/DEPLOYMENT_SUMMARY.md b/deployment/DEPLOYMENT_SUMMARY.md index e83d6ff..6a36603 100644 --- a/deployment/DEPLOYMENT_SUMMARY.md +++ b/deployment/DEPLOYMENT_SUMMARY.md @@ -9,6 +9,10 @@ This directory contains two different kinds of deployment material: Start with [`LIVE_DEPLOYMENT_MAP.md`](./LIVE_DEPLOYMENT_MAP.md). +Primary public explorer surface: `https://blockscout.defi-oracle.io` + +Companion explorer-facing properties may still exist under `https://explorer.d-bis.org` for Snap and related tooling, but the public explorer verification flow should treat `blockscout.defi-oracle.io` as canonical unless a task explicitly targets a companion surface. + The live explorer is currently assembled from separate deployment paths: | Component | Live service | Canonical deploy path | @@ -22,9 +26,10 @@ The live explorer is currently assembled from separate deployment paths: - [`check-explorer-health.sh`](../scripts/check-explorer-health.sh) - [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh) -- `https://explorer.d-bis.org/api/config/capabilities` -- `https://explorer.d-bis.org/explorer-api/v1/track1/bridge/status` -- `https://explorer.d-bis.org/explorer-api/v1/mission-control/stream` +- [`scripts/verify-explorer-access-edge-hook.sh`](../scripts/verify-explorer-access-edge-hook.sh) +- `https://blockscout.defi-oracle.io/api/config/capabilities` +- `https://blockscout.defi-oracle.io/explorer-api/v1/track1/bridge/status` +- `https://blockscout.defi-oracle.io/explorer-api/v1/mission-control/stream` ## Legacy Material In This Directory @@ -35,6 +40,6 @@ These files remain in the repo, but they describe an older generalized package: - `DEPLOYMENT_CHECKLIST.md` - `QUICK_DEPLOY.md` - `systemd/explorer-api.service` -- `systemd/explorer-frontend.service` +- `systemd/solacescanscout-frontend.service` Treat those as scaffold or historical reference unless they have been explicitly updated to match the live split architecture. diff --git a/deployment/DEPLOYMENT_TASKS.md b/deployment/DEPLOYMENT_TASKS.md index a925586..7bb5b06 100644 --- a/deployment/DEPLOYMENT_TASKS.md +++ b/deployment/DEPLOYMENT_TASKS.md @@ -172,25 +172,26 @@ This document provides a detailed checklist of all tasks required to deploy the #### Task 21: Create Systemd Service Files - [ ] Create `/etc/systemd/system/explorer-indexer.service` - [ ] Create `/etc/systemd/system/explorer-api.service` -- [ ] Create `/etc/systemd/system/explorer-frontend.service` -- [ ] Set proper ownership: `chown root:root /etc/systemd/system/explorer-*.service` -- [ ] Set proper permissions: `chmod 644 /etc/systemd/system/explorer-*.service` +- [ ] Create `/etc/systemd/system/solacescanscout-frontend.service` +- [ ] Set proper ownership: `chown root:root /etc/systemd/system/explorer-*.service /etc/systemd/system/solacescanscout-frontend.service` +- [ ] Set proper permissions: `chmod 644 /etc/systemd/system/explorer-*.service /etc/systemd/system/solacescanscout-frontend.service` #### Task 22: Enable and Start Services - [ ] Reload systemd: `systemctl daemon-reload` - [ ] Enable indexer: `systemctl enable explorer-indexer` - [ ] Enable API: `systemctl enable explorer-api` -- [ ] Enable frontend: `systemctl enable explorer-frontend` +- [ ] Enable frontend: `systemctl enable solacescanscout-frontend` - [ ] Start indexer: `systemctl start explorer-indexer` - [ ] Start API: `systemctl start explorer-api` -- [ ] Start frontend: `systemctl start explorer-frontend` +- [ ] Start frontend: `systemctl start solacescanscout-frontend` #### Task 23: Verify Services - [ ] Check indexer status: `systemctl status explorer-indexer` - [ ] Check API status: `systemctl status explorer-api` -- [ ] Check frontend status: `systemctl status explorer-frontend` +- [ ] Check frontend status: `systemctl status solacescanscout-frontend` - [ ] Check indexer logs: `journalctl -u explorer-indexer -f` - [ ] Check API logs: `journalctl -u explorer-api -f` +- [ ] Check frontend logs: `journalctl -u solacescanscout-frontend -f` - [ ] Verify API responds: `curl http://localhost:8080/health` - [ ] Verify frontend responds: `curl http://localhost:3000` @@ -558,4 +559,3 @@ This document provides a detailed checklist of all tasks required to deploy the **Last Updated**: 2024-12-23 **Version**: 1.0.0 - diff --git a/deployment/ENVIRONMENT_TEMPLATE.env b/deployment/ENVIRONMENT_TEMPLATE.env index 29e3c9c..53bc1cd 100644 --- a/deployment/ENVIRONMENT_TEMPLATE.env +++ b/deployment/ENVIRONMENT_TEMPLATE.env @@ -110,6 +110,8 @@ SOUL_MACHINES_API_SECRET= CORS_ALLOWED_ORIGIN= JWT_SECRET=CHANGE_THIS_JWT_SECRET ENCRYPTION_KEY=CHANGE_THIS_ENCRYPTION_KEY_32_BYTES +ACCESS_ADMIN_EMAILS= +ACCESS_INTERNAL_SECRET=CHANGE_THIS_INTERNAL_ACCESS_SECRET # ============================================ # Monitoring (Optional) @@ -126,4 +128,3 @@ ENABLE_WEBSOCKET=true ENABLE_ANALYTICS=true ENABLE_VTM=false ENABLE_XR=false - diff --git a/deployment/INDEX.md b/deployment/INDEX.md index 4bbda07..60c83a2 100644 --- a/deployment/INDEX.md +++ b/deployment/INDEX.md @@ -10,6 +10,7 @@ Complete index of all deployment files and their purposes. | `DEPLOYMENT_TASKS.md` | Detailed 71-task checklist | 561 | | `DEPLOYMENT_CHECKLIST.md` | Interactive deployment checklist | 204 | | `DEPLOYMENT_SUMMARY.md` | Deployment package summary | - | +| `ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md` | RPC/API-key edge enforcement for protected lanes | - | | `QUICK_DEPLOY.md` | Quick command reference | - | | `README.md` | Documentation overview | - | | `INDEX.md` | This file | - | @@ -28,12 +29,16 @@ Complete index of all deployment files and their purposes. | `scripts/setup-backup.sh` | Setup backup system | โ | | `scripts/setup-health-check.sh` | Setup health monitoring | โ | | `scripts/verify-deployment.sh` | Verify deployment | โ | +| `../scripts/render-rpc-access-gate-nginx.sh` | Render lane-specific nginx gate configs for `2101` / `2102` / `2103` | โ | +| `../scripts/install-rpc-access-gate-nginx-via-ssh.sh` | Dry-run-first remote installer for rendered RPC gate configs | โ | | `scripts/full-deploy.sh` | Full automated deployment | โ | ## โ๏ธ Configuration Files ### Nginx - `nginx/explorer.conf` - Complete Nginx reverse proxy configuration +- `common/nginx-rpc-api-key-gate.conf` - Example auth-gated RPC upstream template +- `../scripts/render-rpc-access-gate-nginx.sh` - Concrete renderer for auth-gated RPC upstream configs ### Cloudflare - `cloudflare/tunnel-config.yml` - Cloudflare Tunnel configuration template @@ -41,7 +46,7 @@ Complete index of all deployment files and their purposes. ### Systemd Services - `systemd/explorer-indexer.service` - Indexer service file - `systemd/explorer-api.service` - API service file -- `systemd/explorer-frontend.service` - Frontend service file +- `systemd/solacescanscout-frontend.service` - Next frontend service file - `systemd/cloudflared.service` - Cloudflare Tunnel service file ### Fail2ban @@ -125,8 +130,8 @@ deployment/ # Install services sudo ./deployment/scripts/install-services.sh -sudo systemctl enable explorer-indexer explorer-api explorer-frontend -sudo systemctl start explorer-indexer explorer-api explorer-frontend +sudo systemctl enable explorer-indexer explorer-api solacescanscout-frontend +sudo systemctl start explorer-indexer explorer-api solacescanscout-frontend # Setup Nginx sudo ./deployment/scripts/setup-nginx.sh @@ -142,7 +147,7 @@ sudo ./deployment/scripts/setup-cloudflare-tunnel.sh ```bash # Check status -systemctl status explorer-indexer explorer-api explorer-frontend +systemctl status explorer-indexer explorer-api solacescanscout-frontend # View logs journalctl -u explorer-api -f @@ -193,4 +198,3 @@ sudo ./deployment/scripts/full-deploy.sh --- **All deployment files are ready and documented!** - diff --git a/deployment/LIVE_DEPLOYMENT_MAP.md b/deployment/LIVE_DEPLOYMENT_MAP.md index 053aff1..7201fe9 100644 --- a/deployment/LIVE_DEPLOYMENT_MAP.md +++ b/deployment/LIVE_DEPLOYMENT_MAP.md @@ -1,12 +1,13 @@ # Live Deployment Map -Current production deployment map for `explorer.d-bis.org`. +Current production deployment map for the SolaceScan public explorer surface. This file is the authoritative reference for the live explorer stack as of `2026-04-05`. It supersedes the older monolithic deployment notes in this directory when the question is "what is running in production right now?" ## Public Entry Point -- Public domain: `https://explorer.d-bis.org` +- Canonical public domain: `https://blockscout.defi-oracle.io` +- Companion surface: `https://explorer.d-bis.org` - Primary container: VMID `5000` (`192.168.11.140`, `blockscout-1`) - Public edge: nginx on VMID `5000` @@ -28,6 +29,7 @@ This file is the authoritative reference for the live explorer stack as of `2026 | Next frontend | [`deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh) | Builds the Next standalone bundle and installs `solacescanscout-frontend.service` on port `3000` | | Explorer config assets | [`deploy-explorer-config-to-vmid5000.sh`](../scripts/deploy-explorer-config-to-vmid5000.sh) | Publishes token list, networks, capabilities, topology, verification example, and token icons | | Explorer config/API backend | [`deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh) | Builds and installs `explorer-config-api.service` on port `8081` and normalizes nginx `/explorer-api/v1/*` routing | +| RPC/API-key edge enforcement | [`ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md`](./ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md), [`render-rpc-access-gate-nginx.sh`](../scripts/render-rpc-access-gate-nginx.sh) | Canonical nginx `auth_request` pattern plus renderer for `2101` / `2102` / `2103` lanes using the explorer validator | ## Relay Topology @@ -48,16 +50,16 @@ The explorer backend reads these through `CCIP_RELAY_HEALTH_URL` or `CCIP_RELAY_ The following endpoints currently describe the live deployment contract: -- `https://explorer.d-bis.org/` -- `https://explorer.d-bis.org/bridge` -- `https://explorer.d-bis.org/routes` -- `https://explorer.d-bis.org/liquidity` -- `https://explorer.d-bis.org/api/config/capabilities` -- `https://explorer.d-bis.org/config/CHAIN138_RPC_CAPABILITIES.json` -- `https://explorer.d-bis.org/explorer-api/v1/features` -- `https://explorer.d-bis.org/explorer-api/v1/track1/bridge/status` -- `https://explorer.d-bis.org/explorer-api/v1/mission-control/stream` -- `https://explorer.d-bis.org/token-aggregation/api/v1/routes/matrix` +- `https://blockscout.defi-oracle.io/` +- `https://blockscout.defi-oracle.io/bridge` +- `https://blockscout.defi-oracle.io/routes` +- `https://blockscout.defi-oracle.io/liquidity` +- `https://blockscout.defi-oracle.io/api/config/capabilities` +- `https://blockscout.defi-oracle.io/config/CHAIN138_RPC_CAPABILITIES.json` +- `https://blockscout.defi-oracle.io/explorer-api/v1/features` +- `https://blockscout.defi-oracle.io/explorer-api/v1/track1/bridge/status` +- `https://blockscout.defi-oracle.io/explorer-api/v1/mission-control/stream` +- `https://blockscout.defi-oracle.io/token-aggregation/api/v1/routes/matrix` ## Recommended Rollout Order @@ -78,7 +80,7 @@ When a change spans relays as well: ## Current Gaps And Legacy Footguns -- Older docs in this directory still describe a monolithic `explorer-api.service` plus `explorer-frontend.service` package. That is no longer the production deployment shape. +- Older docs in this directory still describe a retired monolithic API-plus-frontend package. That is no longer the production deployment shape. - [`ALL_VMIDS_ENDPOINTS.md`](../../docs/04-configuration/ALL_VMIDS_ENDPOINTS.md) is still correct at the public ingress level, but it intentionally compresses the explorer into `:80/:443` and Blockscout `:4000`. Use this file for the detailed internal listener split. - There is no single one-shot script in this repo that fully deploys Blockscout, nginx, token aggregation, explorer-config-api, Next frontend, and host-side relays together. Production is currently assembled from the component deploy scripts above. - `mainnet-weth` is deployed but intentionally paused until that bridge lane is funded again. diff --git a/deployment/QUICK_DEPLOY.md b/deployment/QUICK_DEPLOY.md index 11d3514..85e8666 100644 --- a/deployment/QUICK_DEPLOY.md +++ b/deployment/QUICK_DEPLOY.md @@ -26,10 +26,11 @@ pct enter 100 ### Services ```bash # Start all services -systemctl start explorer-indexer explorer-api explorer-frontend +systemctl start explorer-indexer explorer-api solacescanscout-frontend # Check status systemctl status explorer-indexer +journalctl -u solacescanscout-frontend -f journalctl -u explorer-indexer -f # Restart @@ -83,13 +84,13 @@ curl http://localhost:3000 curl http://localhost/api/health # Through Cloudflare -curl https://explorer.d-bis.org/api/health +curl https://blockscout.defi-oracle.io/api/health ``` ## File Locations - **Config**: `/home/explorer/explorer-monorepo/.env` -- **Services**: `/etc/systemd/system/explorer-*.service` +- **Services**: `/etc/systemd/system/explorer-*.service` and `/etc/systemd/system/solacescanscout-frontend.service` - **Nginx**: `/etc/nginx/sites-available/explorer` - **Tunnel**: `/etc/cloudflared/config.yml` - **Logs**: `/var/log/explorer/` and `journalctl -u explorer-*` @@ -127,12 +128,11 @@ journalctl -u cloudflared -f ```bash # Stop all services -systemctl stop explorer-indexer explorer-api explorer-frontend +systemctl stop explorer-indexer explorer-api solacescanscout-frontend # Restore from backup gunzip < backup.sql.gz | psql -U explorer explorer # Restart services -systemctl start explorer-indexer explorer-api explorer-frontend +systemctl start explorer-indexer explorer-api solacescanscout-frontend ``` - diff --git a/deployment/README.md b/deployment/README.md index c30c058..d6ece73 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -20,6 +20,7 @@ That file reflects the live split deployment now in production: - Frontend deploy: [`scripts/deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh) - Config deploy: [`scripts/deploy-explorer-config-to-vmid5000.sh`](../scripts/deploy-explorer-config-to-vmid5000.sh) - Explorer config/API deploy: [`scripts/deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh) +- RPC/API-key edge enforcement: [`ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md`](./ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md) - Public health audit: [`scripts/check-explorer-health.sh`](../scripts/check-explorer-health.sh) - Full public smoke: [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh) diff --git a/deployment/add-csp-http-location.sh b/deployment/add-csp-http-location.sh index fd94793..a6ba72c 100644 --- a/deployment/add-csp-http-location.sh +++ b/deployment/add-csp-http-location.sh @@ -8,7 +8,8 @@ else # Insert CSP line after add_header Cache-Control in first location = / sed -i '/location = \/ {/,/try_files \/index.html =404;/{ /add_header Cache-Control "no-store, no-cache, must-revalidate"/a\ - add_header 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; img-src '\''self'\'' data: https:; font-src '\''self'\'' https://cdnjs.cloudflare.com; connect-src '\''self'\'' https://explorer.d-bis.org wss://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;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always;\ + add_header 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; img-src '\''self'\'' data: https:; font-src '\''self'\'' https://cdnjs.cloudflare.com; connect-src '\''self'\'' https://blockscout.defi-oracle.io wss://blockscout.defi-oracle.io https://explorer.d-bis.org wss://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;" always; }' "$CONFIG" echo "Added CSP to HTTP location = /" fi diff --git a/deployment/common/README.md b/deployment/common/README.md index 55a2940..5385223 100644 --- a/deployment/common/README.md +++ b/deployment/common/README.md @@ -6,7 +6,9 @@ Use as reference or copy into your project. ## Contents - **nginx-api-location.conf** โ Generic `location /api/` proxy snippet (upstream host/port to be adjusted). +- **nginx-rpc-api-key-gate.conf** โ Example `auth_request` pattern for API-key-protected RPC lanes using the explorer access validator. - **systemd-api-service.example** โ Example systemd unit for a REST API (env and paths to be adjusted). +- **../scripts/render-rpc-access-gate-nginx.sh** โ Render a concrete nginx gate config for `core-rpc`, `alltra-rpc`, or `thirdweb-rpc`. - **cloudflare / fail2ban** โ See parent `../cloudflare/` and `../fail2ban/` for full configs. When this is a separate repo, add as submodule at `deployment/common`. diff --git a/deployment/common/nginx-next-frontend-proxy.conf b/deployment/common/nginx-next-frontend-proxy.conf index ee72b06..7cb3692 100644 --- a/deployment/common/nginx-next-frontend-proxy.conf +++ b/deployment/common/nginx-next-frontend-proxy.conf @@ -1,4 +1,4 @@ -# Next.js frontend proxy locations for SolaceScanScout. +# Next.js frontend proxy locations for SolaceScan. # Keep the existing higher-priority locations for: # - /api/ # - /api/config/token-list @@ -32,5 +32,6 @@ location / { proxy_buffering off; proxy_hide_header Cache-Control; add_header Cache-Control "no-store, no-cache, must-revalidate" always; - add_header 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; img-src 'self' data: https:; font-src 'self' https://cdnjs.cloudflare.com; connect-src 'self' https://explorer.d-bis.org wss://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;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header 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; img-src 'self' data: https:; font-src 'self' https://cdnjs.cloudflare.com; connect-src 'self' https://blockscout.defi-oracle.io wss://blockscout.defi-oracle.io https://explorer.d-bis.org wss://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;" always; } diff --git a/deployment/common/nginx-rpc-api-key-gate.conf b/deployment/common/nginx-rpc-api-key-gate.conf new file mode 100644 index 0000000..519f365 --- /dev/null +++ b/deployment/common/nginx-rpc-api-key-gate.conf @@ -0,0 +1,56 @@ +# Example nginx gate for API-key-protected RPC upstreams using the explorer access API. +# This pattern assumes the explorer config/API backend listens on 127.0.0.1:8081 and +# exposes GET /api/v1/access/internal/validate-key for nginx auth_request. +# +# Replace: +# - ACCESS_INTERNAL_SECRET_VALUE with a real shared secret +# - protected-rpc.example.org with the public host you are protecting +# - upstream IP:port with the actual RPC lane (e.g. 192.168.11.212:8545 or 192.168.11.217:8545) +# +# Clients should send the API key as: +# - X-API-Key: sk_live_... +# or +# - Authorization: Bearer sk_live_... + +server { + listen 443 ssl http2; + server_name protected-rpc.example.org; + + # Internal subrequest used by auth_request. + location = /__access_validate_rpc { + internal; + proxy_pass http://127.0.0.1:8081/api/v1/access/internal/validate-key; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Access-Internal-Secret "ACCESS_INTERNAL_SECRET_VALUE"; + proxy_set_header X-API-Key $http_x_api_key; + proxy_set_header Authorization $http_authorization; + proxy_set_header X-Access-Method $request_method; + proxy_set_header X-Access-Request-Count "1"; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + auth_request /__access_validate_rpc; + + # Optional metadata exported from the validator for logging or rate decisions. + auth_request_set $validated_product $upstream_http_x_validated_product; + auth_request_set $validated_tier $upstream_http_x_validated_tier; + auth_request_set $validated_scopes $upstream_http_x_validated_scopes; + auth_request_set $quota_remaining $upstream_http_x_quota_remaining; + + proxy_pass http://192.168.11.217:8545; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Helpful for downstream logs and operational tracing. + proxy_set_header X-Validated-Product $validated_product; + proxy_set_header X-Validated-Tier $validated_tier; + proxy_set_header X-Validated-Scopes $validated_scopes; + proxy_set_header X-Quota-Remaining $quota_remaining; + } +} diff --git a/deployment/common/systemd-api-service.example b/deployment/common/systemd-api-service.example index 15b79af..a7731fc 100644 --- a/deployment/common/systemd-api-service.example +++ b/deployment/common/systemd-api-service.example @@ -17,6 +17,8 @@ Environment=RPC_URL=https://rpc-http-pub.d-bis.org Environment=TOKEN_AGGREGATION_BASE_URL=http://127.0.0.1:3000 Environment=BLOCKSCOUT_INTERNAL_URL=http://127.0.0.1:4000 Environment=EXPLORER_PUBLIC_BASE=https://explorer.d-bis.org +Environment=ACCESS_ADMIN_EMAILS=ops@example.org +Environment=ACCESS_INTERNAL_SECRET=CHANGE_THIS_INTERNAL_ACCESS_SECRET Environment=OPERATOR_SCRIPTS_ROOT=/opt/explorer/scripts Environment=OPERATOR_SCRIPT_ALLOWLIST=check-health.sh,check-bridges.sh Environment=OPERATOR_SCRIPT_TIMEOUT_SEC=120 diff --git a/deployment/scripts/full-deploy.sh b/deployment/scripts/full-deploy.sh index 143193f..87d4a15 100755 --- a/deployment/scripts/full-deploy.sh +++ b/deployment/scripts/full-deploy.sh @@ -74,8 +74,7 @@ echo "Next steps:" echo "1. Configure .env file: /home/explorer/explorer-monorepo/.env" echo "2. Run database migrations" echo "3. Build applications" -echo "4. Start services: systemctl start explorer-indexer explorer-api explorer-frontend" +echo "4. Start services: systemctl start explorer-indexer explorer-api solacescanscout-frontend" echo "5. Configure Cloudflare DNS and SSL" echo "" echo "See DEPLOYMENT_GUIDE.md for detailed instructions" - diff --git a/deployment/scripts/install-services.sh b/deployment/scripts/install-services.sh index 4e439db..f266898 100755 --- a/deployment/scripts/install-services.sh +++ b/deployment/scripts/install-services.sh @@ -11,17 +11,17 @@ echo "Installing systemd service files..." # Copy service files cp "$DEPLOYMENT_DIR/systemd/explorer-indexer.service" /etc/systemd/system/ cp "$DEPLOYMENT_DIR/systemd/explorer-api.service" /etc/systemd/system/ -cp "$DEPLOYMENT_DIR/systemd/explorer-frontend.service" /etc/systemd/system/ +cp "$DEPLOYMENT_DIR/systemd/solacescanscout-frontend.service" /etc/systemd/system/ cp "$DEPLOYMENT_DIR/systemd/cloudflared.service" /etc/systemd/system/ # Set permissions chmod 644 /etc/systemd/system/explorer-*.service +chmod 644 /etc/systemd/system/solacescanscout-frontend.service chmod 644 /etc/systemd/system/cloudflared.service # Reload systemd systemctl daemon-reload echo "Service files installed. Enable with:" -echo " systemctl enable explorer-indexer explorer-api explorer-frontend" -echo " systemctl start explorer-indexer explorer-api explorer-frontend" - +echo " systemctl enable explorer-indexer explorer-api solacescanscout-frontend" +echo " systemctl start explorer-indexer explorer-api solacescanscout-frontend" diff --git a/deployment/scripts/verify-deployment.sh b/deployment/scripts/verify-deployment.sh index 051e803..2a01774 100755 --- a/deployment/scripts/verify-deployment.sh +++ b/deployment/scripts/verify-deployment.sh @@ -15,7 +15,7 @@ ERRORS=0 # Check services echo "Checking services..." -for service in explorer-indexer explorer-api explorer-frontend nginx postgresql; do +for service in explorer-indexer explorer-api solacescanscout-frontend nginx postgresql; do if systemctl is-active --quiet $service; then echo -e "${GREEN}โ${NC} $service is running" else @@ -100,4 +100,3 @@ else echo -e "${RED}โ $ERRORS critical check(s) failed${NC}" exit 1 fi - diff --git a/deployment/systemd/explorer-frontend.service b/deployment/systemd/explorer-frontend.service deleted file mode 100644 index 8dd7caa..0000000 --- a/deployment/systemd/explorer-frontend.service +++ /dev/null @@ -1,33 +0,0 @@ -[Unit] -Description=ChainID 138 Explorer Frontend Service -Documentation=https://github.com/explorer/frontend -After=network.target explorer-api.service -Requires=explorer-api.service - -[Service] -Type=simple -User=explorer -Group=explorer -WorkingDirectory=/home/explorer/explorer-monorepo/frontend -EnvironmentFile=/home/explorer/explorer-monorepo/.env -ExecStart=/usr/bin/npm start -Restart=always -RestartSec=10 -StandardOutput=journal -StandardError=journal -SyslogIdentifier=explorer-frontend - -# Security settings -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=read-only -ReadWritePaths=/home/explorer/explorer-monorepo/frontend - -# Resource limits -LimitNOFILE=65536 -LimitNPROC=4096 - -[Install] -WantedBy=multi-user.target - diff --git a/deployment/systemd/solacescanscout-frontend.service b/deployment/systemd/solacescanscout-frontend.service index 9bc2044..725eac8 100644 --- a/deployment/systemd/solacescanscout-frontend.service +++ b/deployment/systemd/solacescanscout-frontend.service @@ -1,5 +1,5 @@ [Unit] -Description=SolaceScanScout Next Frontend Service +Description=SolaceScan Next Frontend Service After=network.target Wants=network.target diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 41d8f0c..b4a0563 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changelog โ SolaceScanScout Explorer +# Changelog โ SolaceScan Explorer All notable frontend and docs changes are listed here. diff --git a/docs/ERROR_REPORT_AND_FIXES.md b/docs/ERROR_REPORT_AND_FIXES.md index 27e408f..31d1a34 100644 --- a/docs/ERROR_REPORT_AND_FIXES.md +++ b/docs/ERROR_REPORT_AND_FIXES.md @@ -300,7 +300,7 @@ Once the backend is running: ### Backend Logs The backend uses Go's standard `log` package. Logs will show: -- Server startup: `Starting SolaceScanScout REST API server on :8080` +- Server startup: `Starting SolaceScan REST API server on :8080` - Request logs: `GET /api/v2/stats 200 2.5ms` - Errors: Database connection errors, query failures, etc. @@ -330,7 +330,7 @@ Expected response: }, "chain_id": 138, "explorer": { - "name": "SolaceScanScout", + "name": "SolaceScan", "version": "1.0.0" } } @@ -359,4 +359,3 @@ Expected response: --- **Next Steps**: Start the backend server and re-run the diagnostic script to verify all issues are resolved. - diff --git a/docs/EXPLORER_ADDITIONAL_RECOMMENDATIONS.md b/docs/EXPLORER_ADDITIONAL_RECOMMENDATIONS.md index 174d8f4..cde16fa 100644 --- a/docs/EXPLORER_ADDITIONAL_RECOMMENDATIONS.md +++ b/docs/EXPLORER_ADDITIONAL_RECOMMENDATIONS.md @@ -1,4 +1,4 @@ -# SolaceScanScout โ Additional Recommendations +# SolaceScan โ Additional Recommendations This document lists **further improvements** beyond the upgrades already implemented (Tier 1โ3 frontend, API docs, watchlist, labels, i18n, etc.). Items are grouped by effort and dependency (frontend-only vs backend). diff --git a/docs/EXPLORER_API_REFERENCE.md b/docs/EXPLORER_API_REFERENCE.md index e7e2b3d..cd96c6c 100644 --- a/docs/EXPLORER_API_REFERENCE.md +++ b/docs/EXPLORER_API_REFERENCE.md @@ -1,11 +1,11 @@ -# SolaceScanScout Explorer โ API Reference +# SolaceScan Explorer โ API Reference -The SolaceScanScout frontend uses the **Blockscout v2 API** for chain data. When the explorer is served from the same origin (e.g. `https://explorer.d-bis.org` or VM IP), requests go to `/api` and are proxied to Blockscout (port 4000). This document lists the endpoints used by the frontend. +The SolaceScan frontend uses the **Blockscout v2 API** for chain data. When the explorer is served from the same origin (e.g. `https://blockscout.defi-oracle.io` or VM IP), requests go to `/api` and are proxied to Blockscout (port 4000). This document lists the endpoints used by the frontend. ## Base URL -- **Same-origin:** `window.location.origin + '/api'` (e.g. `https://explorer.d-bis.org/api`) -- **Fallback:** `https://explorer.d-bis.org/api` +- **Same-origin:** `window.location.origin + '/api'` (e.g. `https://blockscout.defi-oracle.io/api`) +- **Fallback:** `https://blockscout.defi-oracle.io/api` All paths below are relative to this base (e.g. `/v2/stats` โ `{base}/v2/stats`). @@ -81,7 +81,7 @@ The frontend does not send API keys. Rate limits are determined by the Blockscou ## OpenAPI / Swagger -If your Blockscout instance exposes an OpenAPI (Swagger) spec, it is often at `{base}/api-docs` or `{base}/swagger`. Document that URL for your deployment (e.g. `https://explorer.d-bis.org/api-docs` if enabled). +If your Blockscout instance exposes an OpenAPI (Swagger) spec, it is often at `{base}/api-docs` or `{base}/swagger`. Document that URL for your deployment (e.g. `https://blockscout.defi-oracle.io/api-docs` if enabled). ## Recent changes diff --git a/docs/FINAL_DEPLOYMENT_REPORT.md b/docs/FINAL_DEPLOYMENT_REPORT.md index f601f93..42f9b50 100644 --- a/docs/FINAL_DEPLOYMENT_REPORT.md +++ b/docs/FINAL_DEPLOYMENT_REPORT.md @@ -5,7 +5,7 @@ ## Executive Summary -The SolaceScanScout tiered architecture has been successfully deployed and tested. The API server is running and all core functionality is operational. +The SolaceScan tiered architecture has been successfully deployed and tested. The API server is running and all core functionality is operational. ## Deployment Status diff --git a/docs/METAMASK_AND_PROVIDER_INTEGRATION.md b/docs/METAMASK_AND_PROVIDER_INTEGRATION.md index 4f0c9ad..138fbe9 100644 --- a/docs/METAMASK_AND_PROVIDER_INTEGRATION.md +++ b/docs/METAMASK_AND_PROVIDER_INTEGRATION.md @@ -1,13 +1,13 @@ # MetaMask and Dual-Chain Provider Integration -The explorer (SolaceScanScout) provides add-to-MetaMask and token list discovery for **Chain 138** (DeFi Oracle Meta Mainnet), **Ethereum Mainnet**, and **ALL Mainnet** (651940). +The explorer (SolaceScan) provides add-to-MetaMask and token list discovery for **Chain 138** (DeFi Oracle Meta Mainnet), **Ethereum Mainnet**, and **ALL Mainnet** (651940). ## Explorer as discovery source - **Add to MetaMask:** Use the [Wallet](/wallet) page to add Chain 138, Ethereum Mainnet, or ALL Mainnet to your wallet via `wallet_addEthereumChain`. - **Token list URL:** The explorer API serves the dual-chain token list at: - **Path:** `/api/config/token-list` - - **Full URL:** `{EXPLORER_API_BASE}/api/config/token-list` (e.g. `https://explorer.d-bis.org/api/config/token-list` if the API is on the same origin). + - **Full URL:** `{EXPLORER_API_BASE}/api/config/token-list` (e.g. `https://blockscout.defi-oracle.io/api/config/token-list` if the API is on the same origin). Add this URL in MetaMask **Settings โ Token lists** so tokens for Chain 138 and Mainnet appear automatically. As of April 3, 2026, the public explorer token list exposes `190` entries, including the full Mainnet `cW*` suite. - **Networks config:** `/api/config/networks` returns the same chain params (Chain 138 + Ethereum Mainnet) in JSON for programmatic use. @@ -29,13 +29,13 @@ Discovery is via **token list** (hosted at the explorer token list URL above), * - **Custom MetaMask Snap:** For in-wallet swap quotes, bridge routes, and pricing on Chain 138, see [SNAP_IMPLEMENTATION_ROADMAP.md](../../docs/04-configuration/metamask/SNAP_IMPLEMENTATION_ROADMAP.md). - **Feature parity and optional actions:** [METAMASK_CHAIN138_FEATURE_PARITY_ANALYSIS.md](../../docs/04-configuration/metamask/METAMASK_CHAIN138_FEATURE_PARITY_ANALYSIS.md) โ Section 7 lists optional next steps (Snap, CoinGecko, Consensys outreach, market data API). -## Live explorer (https://explorer.d-bis.org) +## Live explorer (https://blockscout.defi-oracle.io) -- **Wallet page:** https://explorer.d-bis.org/wallet -- **Token list URL:** https://explorer.d-bis.org/api/config/token-list -- **Networks config:** https://explorer.d-bis.org/api/config/networks -- **GRU v2 public rollout status:** https://explorer.d-bis.org/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json -- **GRU v2 deployment queue:** https://explorer.d-bis.org/config/GRU_V2_DEPLOYMENT_QUEUE.json +- **Wallet page:** https://blockscout.defi-oracle.io/wallet +- **Token list URL:** https://blockscout.defi-oracle.io/api/config/token-list +- **Networks config:** https://blockscout.defi-oracle.io/api/config/networks +- **GRU v2 public rollout status:** https://blockscout.defi-oracle.io/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json +- **GRU v2 deployment queue:** https://blockscout.defi-oracle.io/config/GRU_V2_DEPLOYMENT_QUEUE.json For backend deployment and integration tests, see [EXPLORER_D_BIS_ORG_INTEGRATION.md](../../docs/04-configuration/metamask/EXPLORER_D_BIS_ORG_INTEGRATION.md). For token-list publishing, use `explorer-monorepo/scripts/deploy-explorer-config-to-vmid5000.sh`; it now falls back through the Proxmox host automatically when local `pct` is not installed. diff --git a/docs/README.md b/docs/README.md index a199905..d112551 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Explorer Monorepo โ Documentation -Overview of documentation for the ChainID 138 Explorer (SolaceScanScout). +Overview of documentation for the ChainID 138 Explorer (SolaceScan). --- @@ -9,7 +9,7 @@ Overview of documentation for the ChainID 138 Explorer (SolaceScanScout). | Doc | Description | |-----|-------------| | **[INDEX.md](./INDEX.md)** | Full documentation index (bridge, setup, verification, operations) | -| **[EXPLORER_API_ACCESS.md](./EXPLORER_API_ACCESS.md)** | API access, 502 fix, CSP, frontend deploy for https://explorer.d-bis.org | +| **[EXPLORER_API_ACCESS.md](./EXPLORER_API_ACCESS.md)** | API access, 502 fix, CSP, frontend deploy for https://blockscout.defi-oracle.io | | **[../README.md](../README.md)** | Project README: quick start, frontend, architecture, config | --- diff --git a/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md b/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md index 406bcd0..2dcf0ec 100644 --- a/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md +++ b/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md @@ -2,7 +2,7 @@ ## Overview -The SolaceScanScout Explorer has been successfully upgraded to a 4-track tiered architecture with feature-gated access control. +The SolaceScan Explorer has been successfully upgraded to a 4-track tiered architecture with feature-gated access control. ## Implementation Status: โ COMPLETE diff --git a/docs/TIERED_ARCHITECTURE_SETUP.md b/docs/TIERED_ARCHITECTURE_SETUP.md index 34f577d..6637884 100644 --- a/docs/TIERED_ARCHITECTURE_SETUP.md +++ b/docs/TIERED_ARCHITECTURE_SETUP.md @@ -1,6 +1,6 @@ # Tiered Architecture Setup Guide -Complete setup and integration guide for SolaceScanScout tiered architecture. +Complete setup and integration guide for SolaceScan tiered architecture. ## Quick Start diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1db52c6..26c88dd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,21 +1,21 @@ openapi: 3.0.0 info: - title: SolaceScanScout API + title: SolaceScan API version: 1.0.0 description: | - SolaceScanScout - The Defi Oracle Meta Explorer API + SolaceScan API for the Chain 138 explorer surface Comprehensive blockchain explorer API for ChainID 138 with cross-chain bridge monitoring, WETH utilities, and real-time transaction tracking. contact: - name: SolaceScanScout Support - url: https://explorer.d-bis.org + name: SolaceScan Support + url: https://blockscout.defi-oracle.io license: name: MIT url: https://opensource.org/licenses/MIT servers: - - url: https://explorer.d-bis.org/api + - url: https://blockscout.defi-oracle.io/api description: Production server - url: http://localhost:8080/api description: Local development server @@ -307,4 +307,3 @@ components: security: - ApiKeyAuth: [] - diff --git a/docs/api/track-api-contracts.md b/docs/api/track-api-contracts.md index 50af6dc..d8817fb 100644 --- a/docs/api/track-api-contracts.md +++ b/docs/api/track-api-contracts.md @@ -1,6 +1,6 @@ # Track API Contracts -Complete API contract definitions for all 4 tracks of SolaceScanScout Explorer. +Complete API contract definitions for all 4 tracks of SolaceScan Explorer. ## Track 1: Public Meta Explorer (No Auth Required) @@ -778,4 +778,3 @@ Paginated endpoints use consistent pagination: } } ``` - diff --git a/docs/feature-flags/track-feature-matrix.md b/docs/feature-flags/track-feature-matrix.md index aba7674..7e715a7 100644 --- a/docs/feature-flags/track-feature-matrix.md +++ b/docs/feature-flags/track-feature-matrix.md @@ -1,6 +1,6 @@ # Track Feature Matrix -Feature flag mapping for SolaceScanScout Explorer tiered architecture. +Feature flag mapping for SolaceScan Explorer tiered architecture. ## Overview @@ -278,4 +278,3 @@ Get available features for current user. "permissions": [...] } ``` - diff --git a/frontend/FRONTEND_REVIEW.md b/frontend/FRONTEND_REVIEW.md index 71ff6d5..e1f3614 100644 --- a/frontend/FRONTEND_REVIEW.md +++ b/frontend/FRONTEND_REVIEW.md @@ -133,7 +133,7 @@ The frontend has two delivery paths: ## 6. Files Reviewed - `public/index.html` โ full read and grep for escapeHtml, innerHTML, fetch, navigation, wallet. -- `src/app/layout.tsx`, `src/app/page.tsx`, `src/app/wallet/page.tsx` +- Historical note: the reviewed home and wallet surfaces were later consolidated into the Pages Router and now live under `src/pages` with shared components in `src/components`. - `src/pages/_app.tsx`, `src/pages/blocks/index.tsx`, `src/pages/blocks/[number].tsx`, `src/pages/transactions/index.tsx`, `src/pages/transactions/[hash].tsx`, `src/pages/addresses/[address].tsx`, `src/pages/search/index.tsx` - `src/components/common/Card.tsx`, `Button.tsx`, `Table.tsx` - `src/components/blockchain/Address.tsx`, `src/components/wallet/AddToMetaMask.tsx` diff --git a/frontend/ROUTING_CONVENTIONS.md b/frontend/ROUTING_CONVENTIONS.md new file mode 100644 index 0000000..97f8a71 --- /dev/null +++ b/frontend/ROUTING_CONVENTIONS.md @@ -0,0 +1,34 @@ +# Explorer Routing Conventions + +This frontend intentionally uses one canonical public route per explorer surface. + +## Canonical Paths + +- Collections are plural: `/blocks`, `/transactions`, `/addresses`, `/tokens`, `/operations` +- Dynamic page segments are named for the identifier they accept: + - `/blocks/[number]` + - `/transactions/[hash]` + - `/addresses/[address]` + - `/tokens/[address]` +- Search is first-class and canonical at `/search` + +## Legacy Aliases + +- `/more` is a compatibility alias only. +- The canonical route is `/operations`. +- New links, UI copy, docs, and static assets should point to `/operations`. + +## Navigation Rules + +- Use named buckets instead of vague overflow labels. +- Prefer `Explore`, `Data`, and `Operations` over catch-all labels like `More`. +- If a route appears in the navbar, use the same label everywhere else unless there is a strong product reason not to. + +## Router Guardrail + +The canonical public router is `src/pages`. + +- New public routes should be added in `src/pages` unless there is a compelling architectural reason not to. +- `src/app/globals.css` remains the shared stylesheet source and is imported from `src/pages/_app.tsx`. +- New route aliases should be handled centrally in `next.config.js` redirects. +- Avoid introducing duplicate public routes that expose the same content under different names. diff --git a/frontend/libs/frontend-api-client/api-base.test.ts b/frontend/libs/frontend-api-client/api-base.test.ts index 820c420..b64c95c 100644 --- a/frontend/libs/frontend-api-client/api-base.test.ts +++ b/frontend/libs/frontend-api-client/api-base.test.ts @@ -5,10 +5,10 @@ describe('resolveExplorerApiBase', () => { it('prefers an explicit env value when present', () => { expect( resolveExplorerApiBase({ - envValue: 'https://explorer.d-bis.org/', + envValue: 'https://blockscout.defi-oracle.io/', browserOrigin: 'http://127.0.0.1:3000', }) - ).toBe('https://explorer.d-bis.org') + ).toBe('https://blockscout.defi-oracle.io') }) it('falls back to same-origin in the browser when env is empty', () => { diff --git a/frontend/next.config.js b/frontend/next.config.js index 4c442e3..121ec37 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -2,6 +2,25 @@ const nextConfig = { reactStrictMode: true, output: 'standalone', + async redirects() { + return [ + { + source: '/more', + destination: '/operations', + permanent: true, + }, + { + source: '/docs.html', + destination: '/docs', + permanent: true, + }, + { + source: '/docs/transaction-compliance', + destination: '/docs/transaction-review', + permanent: true, + }, + ] + }, // If you see a workspace lockfile warning: align on one package manager (npm or pnpm) in frontend, or ignore for dev/build. env: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? '', diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9fb52ba..fafaf3f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.6.2", "clsx": "^2.0.0", "date-fns": "^3.0.6", + "js-sha3": "^0.9.3", "next": "^14.0.4", "postcss": "^8.4.32", "react": "^18.2.0", @@ -1344,14 +1345,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2114,6 +2115,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -2792,7 +2805,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4912,6 +4925,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-sha3": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", + "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5160,6 +5179,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5743,12 +5774,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -6103,6 +6134,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7025,18 +7068,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 259507f..10227a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "axios": "^1.6.2", "clsx": "^2.0.0", "date-fns": "^3.0.6", + "js-sha3": "^0.9.3", "next": "^14.0.4", "postcss": "^8.4.32", "react": "^18.2.0", diff --git a/frontend/public/acknowledgments.html b/frontend/public/acknowledgments.html index 2e339d9..776fa94 100644 --- a/frontend/public/acknowledgments.html +++ b/frontend/public/acknowledgments.html @@ -3,8 +3,8 @@
-Operator-style view of the architecture in docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md. Diagrams are informational only; contract addresses live in explorer config and repo references. The live Mission Control visual surfaces remain in the main explorer SPA. Deep links: ?tab=mission-control or numeric ?tab=0โ8 (slug per tab).
Operator-style view of the architecture in docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md. Diagrams are informational only; contract addresses live in explorer config and repo references. The main explorer remains the canonical live operational surface. Deep links: ?tab=mission-control or numeric ?tab=0โ8 (slug per tab).