The Chain 138 RPC access product catalog (core-rpc / alltra-rpc /
thirdweb-rpc, each with VMID + HTTP/WS URL + tier + billing model + use
cases + management features) used to be a hardcoded 50-line Go literal
in api/rest/auth.go. The review flagged this as the biggest source of
'magic constants in source' in the backend: changing a partner URL, a
VMID, or a billing model required a Go recompile, and the internal
192.168.11.x CIDR endpoints were baked into the binary.
This PR moves the catalog to backend/config/rpc_products.yaml and adds
a lazy loader so every call site reads from the YAML on first use.
New files:
backend/config/rpc_products.yaml source of truth
backend/api/rest/rpc_products_config.go loader + fallback defaults
backend/api/rest/rpc_products_config_test.go unit tests
Loader path-resolution order (first hit wins):
1. $RPC_PRODUCTS_PATH (absolute or cwd-relative)
2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml
3. <cwd>/backend/config/rpc_products.yaml
4. <cwd>/config/rpc_products.yaml
5. compiled-in defaultRPCAccessProducts fallback (logs a WARNING)
Validation on load:
- every product must have a non-empty slug,
- every product must have a non-empty http_url,
- slugs must be unique across the catalog.
A malformed YAML causes a WARNING + fallback to defaults, never a
silent empty product list.
Call-site changes in auth.go:
- 'var rpcAccessProducts []accessProduct' (literal) -> func
rpcAccessProducts() []accessProduct (forwards to the lazy loader).
- Both existing consumers (/api/v1/access/products handler at line
~369 and findAccessProduct() at line ~627) now call the function.
Zero other behavioural changes; the JSON shape of the response is
byte-identical.
Tests added:
- TestLoadRPCAccessProductsFromRepoDefault: confirms the shipped
YAML loads, produces >=3 products, and contains the 3 expected
slugs with non-empty http_url.
- TestLoadRPCAccessProductsRejectsDuplicateSlug.
- TestLoadRPCAccessProductsRejectsMissingHTTPURL.
Verification:
go build ./... clean
go vet ./... clean
go test ./api/rest/ PASS (new + existing)
go mod tidy pulled yaml.v3 from indirect to direct
Advances completion criterion 7 (no magic constants): 'Chain 138
access products / VMIDs / provider URLs live in a YAML that operators
can change without a rebuild; internal CIDRs are no longer required
to be present in source.'
890 lines
25 KiB
Go
890 lines
25 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"`
|
|
}
|
|
|
|
// rpcAccessProducts returns the Chain 138 RPC access catalog. The source
|
|
// of truth lives in config/rpc_products.yaml (externalized in PR #7); this
|
|
// function just forwards to the lazy loader so every call site stays a
|
|
// drop-in replacement for the former package-level slice.
|
|
func rpcAccessProducts() []accessProduct {
|
|
return rpcAccessProductCatalog()
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|