forked from molecule-ai/molecule-core
Replace all c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
calls across 22 handler files with context-appropriate generic messages
to prevent internal error strings (DB details, validation messages,
file paths) leaking into API responses.
Pattern established:
- ShouldBindJSON failures → "invalid request body" (or "invalid delegation request")
- Validation failures → "invalid workspace ID", "invalid path", etc.
- Server-side errors still logged, only generic message returned to client
References: Security finding from Audit #125 (Stripe key leak via err.Error())
Co-authored-by: Molecule AI Fullstack (floater) <fullstack-floater@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
5.6 KiB
Go
172 lines
5.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// BudgetHandler exposes per-workspace budget read/write endpoints.
|
|
// Routes (all behind WorkspaceAuth middleware):
|
|
//
|
|
// GET /workspaces/:id/budget — current budget_limit, monthly_spend, budget_remaining
|
|
// PATCH /workspaces/:id/budget — set or clear budget_limit
|
|
type BudgetHandler struct{}
|
|
|
|
func NewBudgetHandler() *BudgetHandler { return &BudgetHandler{} }
|
|
|
|
// budgetResponse is the canonical JSON shape for both GET and PATCH responses.
|
|
type budgetResponse struct {
|
|
// BudgetLimit is the monthly spend ceiling in USD cents (null = no limit).
|
|
// budget_limit=500 means $5.00/month.
|
|
BudgetLimit *int64 `json:"budget_limit"`
|
|
// MonthlySpend is the agent's self-reported accumulated LLM API spend
|
|
// for the current month (USD cents). Incremented via heartbeat.
|
|
MonthlySpend int64 `json:"monthly_spend"`
|
|
// BudgetRemaining is null when BudgetLimit is null, otherwise
|
|
// max(0, budget_limit - monthly_spend). Can be negative — we store the
|
|
// actual value so callers can see how far over-budget a workspace is.
|
|
BudgetRemaining *int64 `json:"budget_remaining"`
|
|
}
|
|
|
|
// GetBudget handles GET /workspaces/:id/budget.
|
|
// Returns the workspace's current budget ceiling, accumulated spend, and
|
|
// computed remaining headroom. Both budget_limit and budget_remaining are
|
|
// null when no limit has been configured for the workspace.
|
|
func (h *BudgetHandler) GetBudget(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
var budgetLimit sql.NullInt64
|
|
var monthlySpend int64
|
|
err := db.DB.QueryRowContext(ctx,
|
|
`SELECT budget_limit, COALESCE(monthly_spend, 0)
|
|
FROM workspaces
|
|
WHERE id = $1 AND status != 'removed'`,
|
|
workspaceID,
|
|
).Scan(&budgetLimit, &monthlySpend)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Printf("GetBudget: query failed for %s: %v", workspaceID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
|
|
resp := budgetResponse{
|
|
MonthlySpend: monthlySpend,
|
|
}
|
|
if budgetLimit.Valid {
|
|
limit := budgetLimit.Int64
|
|
resp.BudgetLimit = &limit
|
|
remaining := limit - monthlySpend
|
|
resp.BudgetRemaining = &remaining
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// patchBudgetRequest is the expected JSON body for PATCH /workspaces/:id/budget.
|
|
// budget_limit=null removes the ceiling; a positive integer sets it (USD cents).
|
|
type patchBudgetRequest struct {
|
|
// BudgetLimit pointer so JSON null → nil, absent → parse error (required field).
|
|
BudgetLimit *int64 `json:"budget_limit"`
|
|
}
|
|
|
|
// PatchBudget handles PATCH /workspaces/:id/budget.
|
|
// Accepts {"budget_limit": <int64>} to set a new ceiling, or
|
|
// {"budget_limit": null} to remove an existing ceiling.
|
|
// Returns the updated budget state in the same shape as GetBudget.
|
|
func (h *BudgetHandler) PatchBudget(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
// We need to distinguish between "field absent" and "field = null",
|
|
// so we unmarshal into a raw map first.
|
|
var raw map[string]interface{}
|
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
budgetLimitRaw, ok := raw["budget_limit"]
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "budget_limit field is required"})
|
|
return
|
|
}
|
|
|
|
// Validate and convert the value. JSON numbers decode as float64.
|
|
var budgetArg interface{} // nil → SQL NULL, int64 → new ceiling
|
|
if budgetLimitRaw != nil {
|
|
switch v := budgetLimitRaw.(type) {
|
|
case float64:
|
|
if v < 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "budget_limit must be >= 0 (USD cents)"})
|
|
return
|
|
}
|
|
cv := int64(v)
|
|
budgetArg = cv
|
|
case int64:
|
|
if v < 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "budget_limit must be >= 0 (USD cents)"})
|
|
return
|
|
}
|
|
budgetArg = v
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "budget_limit must be an integer (USD cents) or null"})
|
|
return
|
|
}
|
|
}
|
|
// budgetArg == nil means "clear the ceiling"
|
|
|
|
// Existence check — return 404 for non-existent / removed workspaces.
|
|
var exists bool
|
|
if err := db.DB.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')`,
|
|
workspaceID,
|
|
).Scan(&exists); err != nil || !exists {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
|
return
|
|
}
|
|
|
|
if _, err := db.DB.ExecContext(ctx,
|
|
`UPDATE workspaces SET budget_limit = $2, updated_at = now() WHERE id = $1`,
|
|
workspaceID, budgetArg,
|
|
); err != nil {
|
|
log.Printf("PatchBudget: update failed for %s: %v", workspaceID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
|
return
|
|
}
|
|
|
|
// Re-read the current state so the response reflects exactly what is in
|
|
// the DB, including the monthly_spend the agent has already accumulated.
|
|
var newLimit sql.NullInt64
|
|
var monthlySpend int64
|
|
if err := db.DB.QueryRowContext(ctx,
|
|
`SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1`,
|
|
workspaceID,
|
|
).Scan(&newLimit, &monthlySpend); err != nil {
|
|
log.Printf("PatchBudget: re-read failed for %s: %v", workspaceID, err)
|
|
// Still success — just omit the echo.
|
|
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
|
return
|
|
}
|
|
|
|
resp := budgetResponse{
|
|
MonthlySpend: monthlySpend,
|
|
}
|
|
if newLimit.Valid {
|
|
limit := newLimit.Int64
|
|
resp.BudgetLimit = &limit
|
|
remaining := limit - monthlySpend
|
|
resp.BudgetRemaining = &remaining
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|