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