- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
927 lines
27 KiB
Go
927 lines
27 KiB
Go
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
|
|
func (s *Server) handleAuthNonce(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 auth.NonceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// Generate nonce
|
|
nonceResp, err := s.walletAuth.GenerateNonce(r.Context(), req.Address)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrWalletAuthStorageNotInitialized) {
|
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(nonceResp)
|
|
}
|
|
|
|
// handleAuthWallet handles POST /api/v1/auth/wallet
|
|
func (s *Server) handleAuthWallet(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 auth.WalletAuthRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// Authenticate wallet
|
|
authResp, err := s.walletAuth.AuthenticateWallet(r.Context(), &req)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrWalletAuthStorageNotInitialized) {
|
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
|
return
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|