2985 lines
110 KiB
Go
2985 lines
110 KiB
Go
package userbilling
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
_ "github.com/jackc/pgx/v4/stdlib"
|
|
)
|
|
|
|
const serviceID = "hwlab-user-billing"
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationFS embed.FS
|
|
|
|
type Config struct {
|
|
Addr string
|
|
DatabaseURL string
|
|
RedisURL string
|
|
InitialCredits int64
|
|
CreditPerThousandTok int64
|
|
SessionTTL time.Duration
|
|
ReservationTTL time.Duration
|
|
RegistrationEnabled bool
|
|
InternalToken string
|
|
RequireInternalToken bool
|
|
MigrateOnStart bool
|
|
MigrationTimeout time.Duration
|
|
BootstrapTimeout time.Duration
|
|
StateAuthority string
|
|
RuntimeNamespace string
|
|
ExternalDatabaseLabel string
|
|
BootstrapAdminID string
|
|
BootstrapAdminEmail string
|
|
BootstrapAdminUsername string
|
|
BootstrapAdminDisplayName string
|
|
BootstrapAdminPasswordHash string
|
|
BootstrapAdminPassword string
|
|
}
|
|
|
|
type Server struct {
|
|
config Config
|
|
db *sql.DB
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"displayName"`
|
|
Status string `json:"status"`
|
|
Role string `json:"role"`
|
|
EmailVerified bool `json:"emailVerified"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type Principal struct {
|
|
UserID string `json:"userId"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
Role string `json:"role"`
|
|
Scopes []string `json:"scopes"`
|
|
AuthType string `json:"authType"`
|
|
KeyID string `json:"keyId,omitempty"`
|
|
}
|
|
|
|
type serviceUsageSummary struct {
|
|
ServiceID string `json:"serviceId"`
|
|
ResourceType string `json:"resourceType"`
|
|
Credits int64 `json:"credits"`
|
|
Quantity int64 `json:"quantity"`
|
|
RecordCount int64 `json:"recordCount"`
|
|
LastUsedAt time.Time `json:"lastUsedAt"`
|
|
}
|
|
|
|
type ledgerSummaryRow struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Reason string `json:"reason"`
|
|
DeltaCredits int64 `json:"deltaCredits"`
|
|
BalanceBefore *int64 `json:"balanceBefore,omitempty"`
|
|
BalanceAfter int64 `json:"balanceAfter"`
|
|
Source string `json:"source"`
|
|
Status string `json:"status"`
|
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
OperatorUserID string `json:"operatorUserId,omitempty"`
|
|
OperatorUsername string `json:"operatorUsername,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type adminUsageRow struct {
|
|
ID string `json:"id"`
|
|
ReservationID string `json:"reservationId,omitempty"`
|
|
UserID string `json:"userId"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
APIKeyID string `json:"apiKeyId,omitempty"`
|
|
ServiceID string `json:"serviceId"`
|
|
ResourceType string `json:"resourceType"`
|
|
Unit string `json:"unit"`
|
|
Quantity int64 `json:"quantity"`
|
|
Credits int64 `json:"credits"`
|
|
Status string `json:"status"`
|
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type adminLedgerRow struct {
|
|
ledgerSummaryRow
|
|
UserID string `json:"userId"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type adminAuditQuery struct {
|
|
Page int
|
|
PageSize int
|
|
UserID string
|
|
Search string
|
|
ServiceID string
|
|
ResourceType string
|
|
APIKeyID string
|
|
Status string
|
|
Kind string
|
|
Source string
|
|
IdempotencyKey string
|
|
From string
|
|
To string
|
|
}
|
|
|
|
type creditAdjustResult struct {
|
|
UserID string `json:"userId"`
|
|
BalanceBefore int64 `json:"balanceBefore"`
|
|
BalanceAfter int64 `json:"balanceAfter"`
|
|
DeltaCredits int64 `json:"deltaCredits"`
|
|
LedgerID string `json:"ledgerId"`
|
|
Kind string `json:"kind"`
|
|
Reason string `json:"reason"`
|
|
Source string `json:"source"`
|
|
Status string `json:"status"`
|
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
Idempotent bool `json:"idempotent"`
|
|
}
|
|
|
|
type reservationSummaryRow struct {
|
|
ReservationID string `json:"reservationId"`
|
|
ServiceID string `json:"serviceId"`
|
|
ResourceType string `json:"resourceType"`
|
|
EstimatedCredits int64 `json:"estimatedCredits"`
|
|
EstimatedTokens int64 `json:"estimatedTokens"`
|
|
Status string `json:"status"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type billingPlanSummary struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"displayName"`
|
|
Status string `json:"status"`
|
|
Description string `json:"description"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type resourceEntitlementSummary struct {
|
|
ID string `json:"id,omitempty"`
|
|
PlanID string `json:"planId,omitempty"`
|
|
ResourceType string `json:"resourceType"`
|
|
Enabled bool `json:"enabled"`
|
|
MonthlyQuotaCredits int64 `json:"monthlyQuotaCredits"`
|
|
MonthlyQuotaUnlimited bool `json:"monthlyQuotaUnlimited"`
|
|
MonthlyUsageCredits int64 `json:"monthlyUsageCredits"`
|
|
MonthlyRemainingCredits int64 `json:"monthlyRemainingCredits"`
|
|
MaxConcurrentReservations int64 `json:"maxConcurrentReservations"`
|
|
ConcurrencyUnlimited bool `json:"concurrencyUnlimited"`
|
|
ActiveReservations int64 `json:"activeReservations"`
|
|
RPMLimit int64 `json:"rpmLimit"`
|
|
RPMUnlimited bool `json:"rpmUnlimited"`
|
|
RPMUsed int64 `json:"rpmUsed"`
|
|
LimitSemantics string `json:"limitSemantics"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type adminBillingUserSummary struct {
|
|
User User `json:"user"`
|
|
PlanID string `json:"planId"`
|
|
Plan billingPlanSummary `json:"plan,omitempty"`
|
|
Entitlements []resourceEntitlementSummary `json:"entitlements,omitempty"`
|
|
Credits creditSummary `json:"credits"`
|
|
Usage userUsageAggregate `json:"usage"`
|
|
APIKeys apiKeyAggregate `json:"apiKeys"`
|
|
Reservations reservationAdminSummary `json:"reservations"`
|
|
}
|
|
|
|
type creditSummary struct {
|
|
Balance int64 `json:"balance"`
|
|
Reserved int64 `json:"reserved"`
|
|
Available int64 `json:"available"`
|
|
}
|
|
|
|
type userUsageAggregate struct {
|
|
TotalCredits int64 `json:"totalCredits"`
|
|
TotalQuantity int64 `json:"totalQuantity"`
|
|
RecordCount int64 `json:"recordCount"`
|
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
|
}
|
|
|
|
type apiKeyAggregate struct {
|
|
ActiveCount int64 `json:"activeCount"`
|
|
TotalCount int64 `json:"totalCount"`
|
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
|
}
|
|
|
|
type apiKeySummary struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Prefix string `json:"prefix"`
|
|
Scopes []string `json:"scopes"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
|
RevokedAt *time.Time `json:"revokedAt"`
|
|
}
|
|
|
|
type adminUsersQuery struct {
|
|
Page int
|
|
PageSize int
|
|
Search string
|
|
Status string
|
|
Role string
|
|
}
|
|
|
|
type adminUserDetail struct {
|
|
Summary adminBillingUserSummary `json:"summary"`
|
|
Ledger []ledgerSummaryRow `json:"ledger"`
|
|
APIKeys []apiKeySummary `json:"apiKeys"`
|
|
UsageBy []serviceUsageSummary `json:"usageByService"`
|
|
Reservations []reservationSummaryRow `json:"reservations"`
|
|
}
|
|
|
|
type billingPlanDetail struct {
|
|
Plan billingPlanSummary `json:"plan"`
|
|
Entitlements []resourceEntitlementSummary `json:"entitlements"`
|
|
}
|
|
|
|
type reservationAdminSummary struct {
|
|
ActiveCount int64 `json:"activeCount"`
|
|
Active []reservationSummaryRow `json:"active"`
|
|
}
|
|
|
|
type apiError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func Run(parent context.Context) error {
|
|
cfg := LoadConfig(os.Environ())
|
|
server, err := NewServer(parent, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer server.Close()
|
|
|
|
ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
httpServer := &http.Server{
|
|
Addr: cfg.Addr,
|
|
Handler: server,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
log.Printf("%s listening on %s", serviceID, cfg.Addr)
|
|
errCh <- httpServer.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = httpServer.Shutdown(shutdownCtx)
|
|
return nil
|
|
case err := <-errCh:
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
func LoadConfig(env []string) Config {
|
|
values := map[string]string{}
|
|
for _, item := range env {
|
|
name, value, ok := strings.Cut(item, "=")
|
|
if ok {
|
|
values[name] = value
|
|
}
|
|
}
|
|
port := first(values["HWLAB_USER_BILLING_PORT"], values["PORT"], "6670")
|
|
cfg := Config{
|
|
Addr: first(values["HWLAB_USER_BILLING_ADDR"], ":"+port),
|
|
DatabaseURL: first(values["HWLAB_USER_BILLING_DB_URL"], values["DATABASE_URL"]),
|
|
RedisURL: values["HWLAB_USER_BILLING_REDIS_URL"],
|
|
InitialCredits: parseInt64(values["HWLAB_USER_BILLING_INITIAL_CREDITS"], 0),
|
|
CreditPerThousandTok: parseInt64(values["HWLAB_USER_BILLING_CREDIT_PER_1K_TOKENS"], 1),
|
|
SessionTTL: parseDurationHours(values["HWLAB_USER_BILLING_SESSION_TTL_HOURS"], 720),
|
|
ReservationTTL: parseDurationMinutes(values["HWLAB_USER_BILLING_RESERVATION_TTL_MINUTES"], 30),
|
|
RegistrationEnabled: values["HWLAB_USER_BILLING_REGISTRATION_ENABLED"] != "0",
|
|
InternalToken: values["HWLAB_USER_BILLING_INTERNAL_TOKEN"],
|
|
RequireInternalToken: values["HWLAB_USER_BILLING_REQUIRE_INTERNAL_TOKEN"] == "1",
|
|
MigrateOnStart: values["HWLAB_USER_BILLING_MIGRATE_ON_START"] != "0",
|
|
MigrationTimeout: parseDurationSeconds(values["HWLAB_USER_BILLING_MIGRATION_TIMEOUT_SECONDS"], 20),
|
|
BootstrapTimeout: parseDurationSeconds(values["HWLAB_USER_BILLING_BOOTSTRAP_TIMEOUT_SECONDS"], 10),
|
|
StateAuthority: first(values["HWLAB_USER_BILLING_STATE_AUTHORITY"], "pk01-postgres"),
|
|
RuntimeNamespace: first(values["POD_NAMESPACE"], values["HWLAB_NAMESPACE"], "hwlab-v03"),
|
|
ExternalDatabaseLabel: first(values["HWLAB_USER_BILLING_EXTERNAL_DB_LABEL"], "pk01-external-postgres"),
|
|
BootstrapAdminID: first(values["HWLAB_BOOTSTRAP_ADMIN_ID"], "usr_bootstrap_admin"),
|
|
BootstrapAdminEmail: values["HWLAB_BOOTSTRAP_ADMIN_EMAIL"],
|
|
BootstrapAdminUsername: first(values["HWLAB_BOOTSTRAP_ADMIN_USERNAME"], "admin"),
|
|
BootstrapAdminDisplayName: first(values["HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME"], "HWLAB Admin"),
|
|
BootstrapAdminPasswordHash: values["HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH"],
|
|
BootstrapAdminPassword: values["HWLAB_BOOTSTRAP_ADMIN_PASSWORD"],
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func NewServer(ctx context.Context, cfg Config) (*Server, error) {
|
|
server := &Server{config: cfg, mux: http.NewServeMux()}
|
|
if cfg.DatabaseURL != "" {
|
|
db, err := sql.Open("pgx", cfg.DatabaseURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(envInt("HWLAB_USER_BILLING_DB_POOL_MAX", 4))
|
|
db.SetMaxIdleConns(4)
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
server.db = db
|
|
if cfg.MigrateOnStart {
|
|
migrateCtx, cancel := context.WithTimeout(ctx, durationOrDefault(cfg.MigrationTimeout, 20*time.Second))
|
|
defer cancel()
|
|
if err := server.migrate(migrateCtx); err != nil {
|
|
return nil, fmt.Errorf("migrate user billing schema: %w", err)
|
|
}
|
|
}
|
|
bootstrapCtx, cancel := context.WithTimeout(ctx, durationOrDefault(cfg.BootstrapTimeout, 10*time.Second))
|
|
defer cancel()
|
|
if err := server.ensureBootstrapAdmin(bootstrapCtx); err != nil {
|
|
return nil, fmt.Errorf("ensure bootstrap admin: %w", err)
|
|
}
|
|
}
|
|
server.routes()
|
|
return server, nil
|
|
}
|
|
|
|
func (s *Server) ensureBootstrapAdmin(ctx context.Context) error {
|
|
if s.db == nil {
|
|
return nil
|
|
}
|
|
username := normalizeUsername(s.config.BootstrapAdminUsername)
|
|
if username == "" {
|
|
return nil
|
|
}
|
|
passwordHash := strings.TrimSpace(s.config.BootstrapAdminPasswordHash)
|
|
if passwordHash == "" && strings.TrimSpace(s.config.BootstrapAdminPassword) != "" {
|
|
hash, err := hashPassword(s.config.BootstrapAdminPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
passwordHash = hash
|
|
}
|
|
if passwordHash == "" {
|
|
return nil
|
|
}
|
|
adminID := strings.TrimSpace(first(s.config.BootstrapAdminID, "usr_bootstrap_admin"))
|
|
email := strings.ToLower(strings.TrimSpace(s.config.BootstrapAdminEmail))
|
|
if email == "" {
|
|
email = username + "@bootstrap.hwlab.local"
|
|
}
|
|
displayName := strings.TrimSpace(first(s.config.BootstrapAdminDisplayName, "HWLAB Admin"))
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
result, err := tx.ExecContext(ctx, `UPDATE hwlab_users SET email = $3, display_name = $4, password_hash = $5, status = 'active', role = 'admin', email_verified = true, updated_at = now() WHERE id = $1 OR username = $2`, adminID, username, email, displayName, passwordHash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rows == 0 {
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role, email_verified) VALUES ($1, $2, $3, $4, $5, 'active', 'admin', true)`, adminID, email, username, displayName, passwordHash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_accounts (user_id, balance_credits, reserved_credits, plan_id) VALUES ($1, $2, 0, 'default') ON CONFLICT (user_id) DO NOTHING`, adminID, nonNegative(s.config.InitialCredits))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *Server) Close() {
|
|
if s.db != nil {
|
|
_ = s.db.Close()
|
|
}
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (s *Server) routes() {
|
|
s.route(http.MethodGet, "/health/live", s.handleLive)
|
|
s.route(http.MethodGet, "/health/ready", s.handleReady)
|
|
s.route(http.MethodPost, "/v1/auth/register", s.handleRegister)
|
|
s.route(http.MethodPost, "/v1/auth/login", s.handleLogin)
|
|
s.route(http.MethodPost, "/v1/auth/logout", s.handleLogout)
|
|
s.route(http.MethodPost, "/v1/auth/refresh", s.handleRefresh)
|
|
s.route(http.MethodGet, "/v1/me", s.handleMe)
|
|
s.route(http.MethodPatch, "/v1/me/profile", s.handleUpdateProfile)
|
|
s.route(http.MethodPost, "/v1/me/password", s.handleChangePassword)
|
|
s.route(http.MethodGet, "/v1/billing/summary", s.handleBillingSummary)
|
|
s.route(http.MethodPost, "/v1/redeem", s.handleRedeem)
|
|
s.route(http.MethodGet, "/v1/subscription/summary", s.handleSubscriptionSummary)
|
|
s.route(http.MethodGet, "/v1/payments/summary", s.handlePaymentSummary)
|
|
s.mux.HandleFunc("/v1/api-keys", s.handleAPIKeys)
|
|
s.mux.HandleFunc("/v1/api-keys/", s.handleAPIKeyByID)
|
|
s.route(http.MethodPost, "/internal/auth/introspect", s.handleIntrospect)
|
|
s.route(http.MethodPost, "/internal/billing/preflight", s.handleBillingPreflight)
|
|
s.route(http.MethodPost, "/internal/billing/record", s.handleBillingRecord)
|
|
s.route(http.MethodPost, "/internal/billing/release", s.handleBillingRelease)
|
|
s.route(http.MethodGet, "/internal/admin/billing/summary", s.handleAdminBillingSummary)
|
|
s.route(http.MethodGet, "/internal/admin/billing/plans", s.handleAdminBillingPlans)
|
|
s.route(http.MethodGet, "/internal/admin/usage", s.handleAdminUsage)
|
|
s.route(http.MethodGet, "/internal/admin/usage/export", s.handleAdminUsageExport)
|
|
s.route(http.MethodGet, "/internal/admin/ledger", s.handleAdminLedger)
|
|
s.route(http.MethodGet, "/internal/admin/ledger/export", s.handleAdminLedgerExport)
|
|
s.mux.HandleFunc("/internal/admin/redeem-codes", s.handleAdminRedeemCodes)
|
|
s.mux.HandleFunc("/internal/admin/redeem-codes/", s.handleAdminRedeemCodeByID)
|
|
s.route(http.MethodGet, "/internal/admin/redeem-redemptions", s.handleAdminRedeemRedemptions)
|
|
s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust)
|
|
s.mux.HandleFunc("/internal/admin/users", s.handleAdminUsers)
|
|
s.mux.HandleFunc("/internal/admin/users/", s.handleAdminUserByID)
|
|
s.route(http.MethodPost, "/internal/admin/users/status", s.handleAdminUserStatus)
|
|
}
|
|
|
|
func (s *Server) route(method, path string, handler http.HandlerFunc) {
|
|
s.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != method {
|
|
w.Header().Set("Allow", method)
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
return
|
|
}
|
|
handler(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) migrate(ctx context.Context) error {
|
|
if s.db == nil {
|
|
return nil
|
|
}
|
|
files, err := fs.Glob(migrationFS, "migrations/*.sql")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sort.Strings(files)
|
|
for _, name := range files {
|
|
body, err := migrationFS.ReadFile(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, statement := range splitSQLStatements(string(body)) {
|
|
if _, err := s.db.ExecContext(ctx, statement); err != nil {
|
|
return fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func splitSQLStatements(text string) []string {
|
|
parts := strings.Split(text, ";\n")
|
|
statements := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
stmt := strings.TrimSpace(part)
|
|
if stmt == "" {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(stmt, ";") {
|
|
stmt += ";"
|
|
}
|
|
statements = append(statements, stmt)
|
|
}
|
|
return statements
|
|
}
|
|
|
|
func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"serviceId": serviceID,
|
|
"status": "ok",
|
|
"stateless": true,
|
|
"stateAuthority": s.config.StateAuthority,
|
|
"database": map[string]any{"configured": s.db != nil, "authority": s.config.ExternalDatabaseLabel},
|
|
"redis": map[string]any{"configured": s.config.RedisURL != "", "role": "cache-only"},
|
|
"namespace": s.config.RuntimeNamespace,
|
|
"registrationEnabled": s.config.RegistrationEnabled,
|
|
"internalTokenRequired": s.config.RequireInternalToken,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|
if s.db == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"serviceId": serviceID, "status": "degraded", "reason": "database-url-not-configured"})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
defer cancel()
|
|
if err := s.db.PingContext(ctx); err != nil {
|
|
s.logDatabaseError("ready_ping", err)
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"serviceId": serviceID, "status": "degraded", "reason": "database-ping-failed", "database": map[string]any{"configured": true, "authority": s.config.ExternalDatabaseLabel, "errorClass": classifyDatabaseError(err)}})
|
|
return
|
|
}
|
|
if err := s.readyWriteProbe(ctx); err != nil {
|
|
s.logDatabaseError("ready_write_probe", err)
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"serviceId": serviceID, "status": "degraded", "reason": "database-transaction-probe-failed", "database": map[string]any{"configured": true, "authority": s.config.ExternalDatabaseLabel, "errorClass": classifyDatabaseError(err)}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"serviceId": serviceID, "status": "ready", "database": map[string]any{"configured": true, "authority": s.config.ExternalDatabaseLabel, "transactionProbe": "write-rollback-ok"}})
|
|
}
|
|
|
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
if !s.registrationAvailable(w) {
|
|
return
|
|
}
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"displayName"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
username := normalizeUsername(req.Username)
|
|
if email == "" || !strings.Contains(email, "@") || username == "" || len(req.Password) < 10 {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_registration", "email, username and a password with at least 10 characters are required")
|
|
return
|
|
}
|
|
passwordHash, err := hashPassword(req.Password)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "password_hash_failed", "could not hash password")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
s.logDatabaseError("register_begin_tx", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "transaction_failed", "could not start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
user := User{ID: newID("usr"), Email: email, Username: username, DisplayName: strings.TrimSpace(req.DisplayName), Status: "active", Role: "user"}
|
|
err = tx.QueryRowContext(ctx, `INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role) VALUES ($1, $2, $3, $4, $5, 'active', 'user') RETURNING created_at`, user.ID, user.Email, user.Username, user.DisplayName, passwordHash).Scan(&user.CreatedAt)
|
|
if err != nil {
|
|
if isDuplicate(err) {
|
|
writeAPIError(w, http.StatusConflict, "user_exists", "email or username already exists")
|
|
return
|
|
}
|
|
writeAPIError(w, http.StatusInternalServerError, "user_create_failed", "could not create user")
|
|
return
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_accounts (user_id, balance_credits, reserved_credits, plan_id) VALUES ($1, $2, 0, 'default')`, user.ID, s.config.InitialCredits)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "credit_account_failed", "could not create credit account")
|
|
return
|
|
}
|
|
if s.config.InitialCredits > 0 {
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, metadata) VALUES ($1, $2, $3, 0, $3, 'grant', 'registration_initial_credit', 'registration', 'applied', '{}'::jsonb)`, newID("led"), user.ID, s.config.InitialCredits)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "credit_ledger_failed", "could not write initial credit ledger")
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "transaction_commit_failed", "could not commit registration")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"user": user, "initialCredits": s.config.InitialCredits})
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
trace := newOtelTraceContext(r.Header.Get("traceparent"))
|
|
if traceparent := trace.traceparent(); traceparent != "" {
|
|
w.Header().Set("traceparent", traceparent)
|
|
w.Header().Set("x-hwlab-otel-trace-id", trace.TraceID)
|
|
}
|
|
startedAt := time.Now().UTC()
|
|
status := http.StatusInternalServerError
|
|
outcome := "error"
|
|
errorCode := ""
|
|
defer func() {
|
|
attrs := map[string]any{
|
|
"http.method": http.MethodPost,
|
|
"http.route": "/v1/auth/login",
|
|
"http.status_code": status,
|
|
"auth.outcome": outcome,
|
|
"user_billing.service_id": serviceID,
|
|
"user_billing.values_redacted": true,
|
|
}
|
|
s.emitOtelSpanAsync("user-billing.auth.login", trace, trace.ServerSpanID, trace.ParentSpanID, otelSpanKindServer, startedAt, attrs, status, errorCode)
|
|
}()
|
|
if !s.databaseAvailable(w) {
|
|
status = http.StatusServiceUnavailable
|
|
outcome = "state_backend_not_configured"
|
|
errorCode = "state_backend_not_configured"
|
|
return
|
|
}
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
status = http.StatusBadRequest
|
|
outcome = "invalid_json"
|
|
errorCode = "invalid_json"
|
|
return
|
|
}
|
|
login := strings.ToLower(strings.TrimSpace(first(req.Email, req.Username)))
|
|
if login == "" || req.Password == "" {
|
|
status = http.StatusBadRequest
|
|
outcome = "invalid_login"
|
|
errorCode = "invalid_login"
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_login", "email or username and password are required")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
var user User
|
|
var passwordHash string
|
|
dbStartedAt := time.Now().UTC()
|
|
err := s.db.QueryRowContext(ctx, `SELECT id, email, username, display_name, password_hash, status, role, email_verified, created_at FROM hwlab_users WHERE lower(email) = $1 OR lower(username) = $1`, login).Scan(&user.ID, &user.Email, &user.Username, &user.DisplayName, &passwordHash, &user.Status, &user.Role, &user.EmailVerified, &user.CreatedAt)
|
|
dbErrorCode := ""
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
dbErrorCode = first(sqlErrorCode(err), "db_query_failed")
|
|
}
|
|
s.emitOtelSpanAsync("user-billing.postgres.login_user", trace, newOtelSpanID(), trace.ServerSpanID, otelSpanKindInternal, dbStartedAt, map[string]any{
|
|
"db.system": "postgresql",
|
|
"db.operation": "SELECT",
|
|
"db.sql.table": "hwlab_users",
|
|
"db.rows_found": err == nil,
|
|
"user_billing.values_redacted": true,
|
|
"db.error_code": dbErrorCode,
|
|
}, 0, dbErrorCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
status = http.StatusUnauthorized
|
|
outcome = "invalid_credentials"
|
|
errorCode = "invalid_credentials"
|
|
writeAPIError(w, http.StatusUnauthorized, "invalid_credentials", "invalid email, username or password")
|
|
return
|
|
}
|
|
if err != nil {
|
|
status = http.StatusInternalServerError
|
|
outcome = "db_error"
|
|
errorCode = first(dbErrorCode, "login_failed")
|
|
writeAPIError(w, http.StatusInternalServerError, errorCode, "could not load user")
|
|
return
|
|
}
|
|
if !verifyPassword(passwordHash, req.Password) {
|
|
status = http.StatusUnauthorized
|
|
outcome = "invalid_credentials"
|
|
errorCode = "invalid_credentials"
|
|
writeAPIError(w, http.StatusUnauthorized, "invalid_credentials", "invalid email, username or password")
|
|
return
|
|
}
|
|
if user.Status != "active" {
|
|
status = http.StatusForbidden
|
|
outcome = "user_disabled"
|
|
errorCode = "user_disabled"
|
|
writeAPIError(w, http.StatusForbidden, "user_disabled", "user is not active")
|
|
return
|
|
}
|
|
sessionToken := "hws_" + randomToken(32)
|
|
expiresAt := time.Now().UTC().Add(s.config.SessionTTL)
|
|
_, err = s.db.ExecContext(ctx, `INSERT INTO hwlab_user_sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)`, newID("ses"), user.ID, tokenHash(sessionToken), expiresAt)
|
|
if err != nil {
|
|
status = http.StatusInternalServerError
|
|
outcome = "session_create_failed"
|
|
errorCode = first(sqlErrorCode(err), "session_create_failed")
|
|
writeAPIError(w, http.StatusInternalServerError, errorCode, "could not create session")
|
|
return
|
|
}
|
|
status = http.StatusOK
|
|
outcome = "session_created"
|
|
writeJSON(w, http.StatusOK, map[string]any{"token": sessionToken, "tokenType": "Bearer", "expiresAt": expiresAt, "user": user})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
token := bearerToken(r)
|
|
if token == "" {
|
|
writeAPIError(w, http.StatusUnauthorized, "missing_bearer", "authorization bearer token is required")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
|
defer cancel()
|
|
_, _ = s.db.ExecContext(ctx, `UPDATE hwlab_user_sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, tokenHash(token))
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if principal.AuthType != "session" {
|
|
writeAPIError(w, http.StatusForbidden, "session_required", "only session tokens can be refreshed")
|
|
return
|
|
}
|
|
expiresAt := time.Now().UTC().Add(s.config.SessionTTL)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
|
defer cancel()
|
|
_, err := s.db.ExecContext(ctx, `UPDATE hwlab_user_sessions SET expires_at = $1 WHERE token_hash = $2 AND revoked_at IS NULL`, expiresAt, tokenHash(bearerToken(r)))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "session_refresh_failed", "could not refresh session")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"expiresAt": expiresAt})
|
|
}
|
|
|
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
balance, reserved, _ := s.accountBalance(r.Context(), principal.UserID)
|
|
writeJSON(w, http.StatusOK, map[string]any{"principal": principal, "credits": map[string]any{"balance": balance, "reserved": reserved, "available": balance - reserved}})
|
|
}
|
|
|
|
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Username *string `json:"username"`
|
|
DisplayName *string `json:"displayName"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
updates := []string{}
|
|
args := []any{principal.UserID}
|
|
if req.Username != nil {
|
|
username := normalizeUsername(*req.Username)
|
|
if strings.TrimSpace(*req.Username) != "" && username == "" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_username", "username must be 3-64 characters and contain only lowercase letters, digits, '-' or '_'")
|
|
return
|
|
}
|
|
if username != "" && username != principal.Username {
|
|
args = append(args, username)
|
|
updates = append(updates, "username = $"+strconv.Itoa(len(args)))
|
|
}
|
|
}
|
|
if req.DisplayName != nil {
|
|
args = append(args, strings.TrimSpace(*req.DisplayName))
|
|
updates = append(updates, "display_name = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if len(updates) == 0 {
|
|
writeAPIError(w, http.StatusBadRequest, "no_profile_fields", "at least one profile field must be provided")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
var user User
|
|
query := `UPDATE hwlab_users SET ` + strings.Join(updates, ", ") + `, updated_at = now() WHERE id = $1 RETURNING id, email, username, display_name, status, role, email_verified, created_at`
|
|
err := s.db.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.DisplayName, &user.Status, &user.Role, &user.EmailVerified, &user.CreatedAt)
|
|
if err != nil {
|
|
if isDuplicate(err) {
|
|
writeAPIError(w, http.StatusConflict, "username_exists", "username already exists")
|
|
return
|
|
}
|
|
writeAPIError(w, http.StatusInternalServerError, "profile_update_failed", "could not update profile")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-profile-v1", "updated": true, "user": user, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if principal.AuthType != "session" {
|
|
writeAPIError(w, http.StatusForbidden, "session_required", "only session-authenticated users can change password")
|
|
return
|
|
}
|
|
var req struct {
|
|
CurrentPassword string `json:"currentPassword"`
|
|
Password string `json:"password"`
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
newPassword := first(req.NewPassword, req.Password)
|
|
if req.CurrentPassword == "" || len(newPassword) < 10 {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_password_change", "currentPassword and a new password with at least 10 characters are required")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
var currentHash string
|
|
if err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM hwlab_users WHERE id = $1 AND status = 'active'`, principal.UserID).Scan(¤tHash); err != nil {
|
|
writeAPIError(w, http.StatusUnauthorized, "invalid_subject", "active user was not found")
|
|
return
|
|
}
|
|
if !verifyPassword(currentHash, req.CurrentPassword) {
|
|
writeAPIError(w, http.StatusUnauthorized, "invalid_current_password", "current password is invalid")
|
|
return
|
|
}
|
|
passwordHash, err := hashPassword(newPassword)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "password_hash_failed", "could not hash password")
|
|
return
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, `UPDATE hwlab_users SET password_hash = $2, updated_at = now() WHERE id = $1`, principal.UserID, passwordHash); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "password_update_failed", "could not update password")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-profile-v1", "changed": true, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := queryLimit(r, "limit", 20, 100)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
balance, reserved, err := s.accountBalance(ctx, principal.UserID)
|
|
if err != nil {
|
|
s.logDatabaseError("billing_summary_account", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load credit account")
|
|
return
|
|
}
|
|
totalCredits, totalQuantity, recordCount, err := s.usageTotals(ctx, principal.UserID)
|
|
if err != nil {
|
|
s.logDatabaseError("billing_summary_usage_totals", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load usage totals")
|
|
return
|
|
}
|
|
byService, err := s.usageByService(ctx, principal.UserID, 20)
|
|
if err != nil {
|
|
s.logDatabaseError("billing_summary_usage_by_service", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load service usage")
|
|
return
|
|
}
|
|
ledgerRows, err := s.ledgerRows(ctx, principal.UserID, limit)
|
|
if err != nil {
|
|
s.logDatabaseError("billing_summary_ledger", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load credit ledger")
|
|
return
|
|
}
|
|
reservations, err := s.activeReservations(ctx, principal.UserID, 20)
|
|
if err != nil {
|
|
s.logDatabaseError("billing_summary_reservations", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load active reservations")
|
|
return
|
|
}
|
|
planID, plan, entitlements, err := s.accountPlanContext(ctx, principal.UserID)
|
|
if err != nil {
|
|
s.logDatabaseError("billing_summary_entitlements", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load billing plan entitlements")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"contractVersion": "user-billing-summary-v1",
|
|
"serviceId": serviceID,
|
|
"status": "ok",
|
|
"principal": principal,
|
|
"credits": map[string]any{
|
|
"balance": balance,
|
|
"reserved": reserved,
|
|
"available": balance - reserved,
|
|
},
|
|
"planId": planID,
|
|
"plan": plan,
|
|
"entitlements": entitlements,
|
|
"usage": map[string]any{
|
|
"totalCredits": totalCredits,
|
|
"totalQuantity": totalQuantity,
|
|
"recordCount": recordCount,
|
|
"byService": byService,
|
|
},
|
|
"ledger": map[string]any{
|
|
"count": len(ledgerRows),
|
|
"limit": limit,
|
|
"rows": ledgerRows,
|
|
},
|
|
"reservations": map[string]any{
|
|
"activeCount": len(reservations),
|
|
"active": reservations,
|
|
},
|
|
"state": map[string]any{
|
|
"stateless": true,
|
|
"stateAuthority": s.config.StateAuthority,
|
|
"database": map[string]any{"authority": s.config.ExternalDatabaseLabel},
|
|
"redis": map[string]any{"role": "cache-only"},
|
|
},
|
|
"valuesRedacted": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handleListAPIKeys(w, r)
|
|
case http.MethodPost:
|
|
s.handleCreateAPIKey(w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(first(req.Name, "default"))
|
|
scopes := req.Scopes
|
|
if len(scopes) == 0 {
|
|
scopes = []string{"api"}
|
|
}
|
|
scopesJSON, _ := json.Marshal(scopes)
|
|
key := "hwl_" + randomToken(32)
|
|
keyID := newID("key")
|
|
prefix := key[:min(len(key), 16)]
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO hwlab_api_keys (id, user_id, name, key_prefix, key_hash, scopes_json) VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, keyID, principal.UserID, name, prefix, tokenHash(key), string(scopesJSON))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "api_key_create_failed", "could not create API key")
|
|
return
|
|
}
|
|
summary := apiKeySummary{ID: keyID, Name: name, Prefix: prefix, Scopes: scopes, Status: "active", CreatedAt: time.Now().UTC()}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"contractVersion": "user-api-key-v1", "id": keyID, "key": key, "secret": key, "apiKey": summary, "prefix": prefix, "scopes": scopes, "created": true, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
keys, err := s.apiKeysForUser(ctx, principal.UserID)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "api_key_list_failed", "could not list API keys")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-api-key-v1", "keys": keys, "count": len(keys), "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleAPIKeyByID(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
keyID := strings.TrimPrefix(r.URL.Path, "/v1/api-keys/")
|
|
keyID = strings.TrimSpace(keyID)
|
|
if keyID == "" || strings.Contains(keyID, "/") {
|
|
writeAPIError(w, http.StatusNotFound, "api_key_not_found", "API key was not found")
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodPatch:
|
|
s.handleUpdateAPIKey(w, r, principal.UserID, keyID)
|
|
case http.MethodDelete:
|
|
s.handleRevokeAPIKey(w, r, principal.UserID, keyID)
|
|
default:
|
|
w.Header().Set("Allow", "PATCH, DELETE")
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleUpdateAPIKey(w http.ResponseWriter, r *http.Request, userID, keyID string) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Scopes []string `json:"scopes"`
|
|
Status string `json:"status"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
updates := []string{}
|
|
args := []any{keyID, userID}
|
|
if strings.TrimSpace(req.Name) != "" {
|
|
args = append(args, strings.TrimSpace(req.Name))
|
|
updates = append(updates, "name = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if len(req.Scopes) > 0 {
|
|
scopesJSON, _ := json.Marshal(req.Scopes)
|
|
args = append(args, string(scopesJSON))
|
|
updates = append(updates, "scopes_json = $"+strconv.Itoa(len(args))+"::jsonb")
|
|
}
|
|
status := strings.TrimSpace(strings.ToLower(req.Status))
|
|
if status != "" {
|
|
if status != "revoked" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_status", "only revoked status can be set through user API key update")
|
|
return
|
|
}
|
|
updates = append(updates, "status = 'revoked'", "revoked_at = now()")
|
|
}
|
|
if len(updates) == 0 {
|
|
key, err := s.apiKeyForUser(ctx, userID, keyID)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusNotFound, "api_key_not_found", "API key was not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-api-key-v1", "key": key, "updated": false, "valuesRedacted": true})
|
|
return
|
|
}
|
|
query := `UPDATE hwlab_api_keys SET ` + strings.Join(updates, ", ") + ` WHERE id = $1 AND user_id = $2 RETURNING id, name, key_prefix, scopes_json::text, status, created_at, last_used_at, revoked_at`
|
|
key, err := scanAPIKey(s.db.QueryRowContext(ctx, query, args...))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeAPIError(w, http.StatusNotFound, "api_key_not_found", "API key was not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "api_key_update_failed", "could not update API key")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-api-key-v1", "key": key, "updated": true, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request, userID, keyID string) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
key, err := scanAPIKey(s.db.QueryRowContext(ctx, `UPDATE hwlab_api_keys SET status = 'revoked', revoked_at = COALESCE(revoked_at, now()) WHERE id = $1 AND user_id = $2 RETURNING id, name, key_prefix, scopes_json::text, status, created_at, last_used_at, revoked_at`, keyID, userID))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeAPIError(w, http.StatusNotFound, "api_key_not_found", "API key was not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "api_key_revoke_failed", "could not revoke API key")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-api-key-v1", "key": key, "revoked": true, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleIntrospect(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
var req struct {
|
|
Token string `json:"token"`
|
|
APIKey string `json:"apiKey"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
token := first(req.Token, req.APIKey)
|
|
principal, err := s.principalFromToken(r.Context(), token)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"active": false})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"active": true, "principal": principal})
|
|
}
|
|
|
|
func (s *Server) handleBillingPreflight(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
var req struct {
|
|
Token string `json:"token"`
|
|
APIKey string `json:"apiKey"`
|
|
UserID string `json:"userId"`
|
|
ServiceID string `json:"serviceId"`
|
|
EstimatedCredits int64 `json:"estimatedCredits"`
|
|
EstimatedTokens int64 `json:"estimatedTokens"`
|
|
IdempotencyKey string `json:"idempotencyKey"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
principal, err := s.principalForBilling(r.Context(), r, req.Token, req.APIKey, req.UserID)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusUnauthorized, "invalid_subject", "billing subject is not active")
|
|
return
|
|
}
|
|
service := strings.TrimSpace(req.ServiceID)
|
|
if service == "" {
|
|
writeAPIError(w, http.StatusBadRequest, "missing_service", "serviceId is required")
|
|
return
|
|
}
|
|
estimated := s.estimateCredits(req.EstimatedCredits, req.EstimatedTokens)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
reservation, err := s.reserveCredits(ctx, principal.UserID, principal.KeyID, service, estimated, req.EstimatedTokens, strings.TrimSpace(req.IdempotencyKey), req.Metadata)
|
|
if err != nil {
|
|
if errors.Is(err, errInsufficientCredits) {
|
|
writeJSON(w, http.StatusPaymentRequired, map[string]any{"allowed": false, "reason": "insufficient_credits"})
|
|
return
|
|
}
|
|
var limitErr billingLimitError
|
|
if errors.As(err, &limitErr) {
|
|
writeJSON(w, limitErr.Status, map[string]any{"allowed": false, "reason": limitErr.Code, "error": apiError{Code: limitErr.Code, Message: limitErr.Message}})
|
|
return
|
|
}
|
|
writeAPIError(w, http.StatusInternalServerError, "billing_preflight_failed", "could not reserve credits")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, reservation)
|
|
}
|
|
|
|
func (s *Server) handleBillingRecord(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
var req struct {
|
|
ReservationID string `json:"reservationId"`
|
|
Token string `json:"token"`
|
|
APIKey string `json:"apiKey"`
|
|
UserID string `json:"userId"`
|
|
ServiceID string `json:"serviceId"`
|
|
UsedCredits int64 `json:"usedCredits"`
|
|
UsedTokens int64 `json:"usedTokens"`
|
|
IdempotencyKey string `json:"idempotencyKey"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
result, err := s.recordUsage(ctx, r, req.ReservationID, req.Token, req.APIKey, req.UserID, req.ServiceID, req.UsedCredits, req.UsedTokens, req.IdempotencyKey, req.Metadata)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "billing_record_failed", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) handleBillingRelease(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
var req struct {
|
|
ReservationID string `json:"reservationId"`
|
|
ServiceID string `json:"serviceId"`
|
|
Reason string `json:"reason"`
|
|
IdempotencyKey string `json:"idempotencyKey"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
result, err := s.releaseReservation(ctx, req.ReservationID, req.ServiceID, req.Reason, req.IdempotencyKey, req.Metadata)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "billing_release_failed", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handleAdminUserList(w, r)
|
|
case http.MethodPost:
|
|
s.handleAdminCreateUser(w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminUserByID(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
userID := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/internal/admin/users/"))
|
|
if userID == "" || strings.Contains(userID, "/") {
|
|
writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found")
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handleAdminUserDetail(w, r, userID)
|
|
case http.MethodPatch:
|
|
s.handleAdminUpdateUser(w, r, userID)
|
|
default:
|
|
w.Header().Set("Allow", "GET, PATCH")
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminUserList(w http.ResponseWriter, r *http.Request) {
|
|
query := adminUsersQueryFromRequest(r)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
users, total, err := s.adminUsers(ctx, query)
|
|
if err != nil {
|
|
s.logDatabaseError("admin_users_list", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "admin_users_failed", "could not load users")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"contractVersion": "user-billing-admin-users-v1",
|
|
"users": users,
|
|
"count": len(users),
|
|
"total": total,
|
|
"pagination": map[string]any{
|
|
"page": query.Page,
|
|
"pageSize": query.PageSize,
|
|
"offset": (query.Page - 1) * query.PageSize,
|
|
},
|
|
"filters": map[string]any{"search": query.Search, "status": query.Status, "role": query.Role},
|
|
"state": map[string]any{
|
|
"stateless": true,
|
|
"stateAuthority": s.config.StateAuthority,
|
|
"database": map[string]any{"authority": s.config.ExternalDatabaseLabel},
|
|
"redis": map[string]any{"role": "cache-only"},
|
|
},
|
|
"valuesRedacted": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAdminUserDetail(w http.ResponseWriter, r *http.Request, userID string) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
detail, err := s.adminUserDetail(ctx, userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logDatabaseError("admin_user_detail", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "admin_user_detail_failed", "could not load user detail")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "detail": detail, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"displayName"`
|
|
Password string `json:"password"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
InitialCredits int64 `json:"initialCredits"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
username := normalizeUsername(req.Username)
|
|
role := normalizeRole(req.Role, "user")
|
|
status := normalizeUserStatus(req.Status, "active")
|
|
if email == "" || !strings.Contains(email, "@") || username == "" || len(req.Password) < 10 || role == "" || status == "" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_admin_user", "email, username, role, status and a password with at least 10 characters are required")
|
|
return
|
|
}
|
|
passwordHash, err := hashPassword(req.Password)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "password_hash_failed", "could not hash password")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "transaction_failed", "could not start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
user := User{ID: newID("usr"), Email: email, Username: username, DisplayName: strings.TrimSpace(req.DisplayName), Status: status, Role: role}
|
|
err = tx.QueryRowContext(ctx, `INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING email_verified, created_at`, user.ID, user.Email, user.Username, user.DisplayName, passwordHash, status, role).Scan(&user.EmailVerified, &user.CreatedAt)
|
|
if err != nil {
|
|
if isDuplicate(err) {
|
|
writeAPIError(w, http.StatusConflict, "user_exists", "email or username already exists")
|
|
return
|
|
}
|
|
writeAPIError(w, http.StatusInternalServerError, "user_create_failed", "could not create user")
|
|
return
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_accounts (user_id, balance_credits, reserved_credits, plan_id) VALUES ($1, $2, 0, 'default')`, user.ID, nonNegative(req.InitialCredits))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "credit_account_failed", "could not create credit account")
|
|
return
|
|
}
|
|
if req.InitialCredits > 0 {
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, metadata) VALUES ($1, $2, $3, 0, $3, 'admin_grant', 'admin_create_user_initial_credit', 'admin', 'applied', '{}'::jsonb)`, newID("led"), user.ID, req.InitialCredits)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "credit_ledger_failed", "could not write initial credit ledger")
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "transaction_commit_failed", "could not commit user creation")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"contractVersion": "user-billing-admin-user-v1", "created": true, "user": user, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, userID string) {
|
|
var req struct {
|
|
Email *string `json:"email"`
|
|
Username *string `json:"username"`
|
|
DisplayName *string `json:"displayName"`
|
|
Password *string `json:"password"`
|
|
Role *string `json:"role"`
|
|
Status *string `json:"status"`
|
|
PlanID *string `json:"planId"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
updates := []string{}
|
|
args := []any{userID}
|
|
if req.Email != nil {
|
|
email := strings.ToLower(strings.TrimSpace(*req.Email))
|
|
if email == "" || !strings.Contains(email, "@") {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_email", "email is invalid")
|
|
return
|
|
}
|
|
args = append(args, email)
|
|
updates = append(updates, "email = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if req.Username != nil {
|
|
username := normalizeUsername(*req.Username)
|
|
if username == "" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_username", "username is invalid")
|
|
return
|
|
}
|
|
args = append(args, username)
|
|
updates = append(updates, "username = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if req.DisplayName != nil {
|
|
args = append(args, strings.TrimSpace(*req.DisplayName))
|
|
updates = append(updates, "display_name = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if req.Role != nil {
|
|
role := normalizeRole(*req.Role, "")
|
|
if role == "" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_role", "role must be user or admin")
|
|
return
|
|
}
|
|
args = append(args, role)
|
|
updates = append(updates, "role = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if req.Status != nil {
|
|
status := normalizeUserStatus(*req.Status, "")
|
|
if status == "" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_status", "status must be active, disabled or pending")
|
|
return
|
|
}
|
|
args = append(args, status)
|
|
updates = append(updates, "status = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if req.Password != nil {
|
|
if len(*req.Password) < 10 {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_password", "password must be at least 10 characters")
|
|
return
|
|
}
|
|
passwordHash, err := hashPassword(*req.Password)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "password_hash_failed", "could not hash password")
|
|
return
|
|
}
|
|
args = append(args, passwordHash)
|
|
updates = append(updates, "password_hash = $"+strconv.Itoa(len(args)))
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
planID := ""
|
|
if req.PlanID != nil {
|
|
planID = strings.TrimSpace(*req.PlanID)
|
|
if planID == "" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_plan", "planId is required when updating a billing plan")
|
|
return
|
|
}
|
|
}
|
|
if len(updates) == 0 && planID == "" {
|
|
detail, err := s.adminUserDetail(ctx, userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "admin_user_detail_failed", "could not load user detail")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": false, "detail": detail, "valuesRedacted": true})
|
|
return
|
|
}
|
|
if len(updates) == 0 {
|
|
detail, err := s.updateUserPlan(ctx, userID, planID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeAPIError(w, http.StatusNotFound, "user_or_plan_not_found", "user or plan not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "plan_update_failed", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": true, "detail": detail, "valuesRedacted": true})
|
|
return
|
|
}
|
|
query := `UPDATE hwlab_users SET ` + strings.Join(updates, ", ") + `, updated_at = now() WHERE id = $1 RETURNING id, email, username, display_name, status, role, email_verified, created_at`
|
|
var user User
|
|
err := s.db.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.DisplayName, &user.Status, &user.Role, &user.EmailVerified, &user.CreatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
if isDuplicate(err) {
|
|
writeAPIError(w, http.StatusConflict, "user_exists", "email or username already exists")
|
|
return
|
|
}
|
|
writeAPIError(w, http.StatusInternalServerError, "user_update_failed", "could not update user")
|
|
return
|
|
}
|
|
if planID != "" {
|
|
if _, err := s.updateUserPlan(ctx, userID, planID); err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "plan_update_failed", err.Error())
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": true, "user": user, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminCreditAdjust(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
var req struct {
|
|
UserID string `json:"userId"`
|
|
DeltaCredits int64 `json:"deltaCredits"`
|
|
Reason string `json:"reason"`
|
|
IdempotencyKey string `json:"idempotencyKey"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.UserID) == "" || req.DeltaCredits == 0 {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_adjustment", "userId and non-zero deltaCredits are required")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
adjustment, err := s.adjustCredits(ctx, req.UserID, req.DeltaCredits, "admin_adjust", req.Reason, req.IdempotencyKey, req.Metadata)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "credit_adjust_failed", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "balance": adjustment.BalanceAfter, "adjustment": adjustment, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminUserStatus(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
var req struct {
|
|
UserID string `json:"userId"`
|
|
Status string `json:"status"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Status != "active" && req.Status != "disabled" && req.Status != "pending" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_status", "status must be active, disabled or pending")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
res, err := s.db.ExecContext(ctx, `UPDATE hwlab_users SET status = $2, updated_at = now() WHERE id = $1`, req.UserID, req.Status)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "status_update_failed", "could not update user status")
|
|
return
|
|
}
|
|
affected, _ := res.RowsAffected()
|
|
if affected == 0 {
|
|
writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "status": req.Status})
|
|
}
|
|
|
|
func (s *Server) handleAdminBillingSummary(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
limit := queryLimit(r, "limit", 50, 100)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
users, err := s.adminBillingUsers(ctx, limit)
|
|
if err != nil {
|
|
s.logDatabaseError("admin_billing_summary_users", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "admin_billing_summary_failed", "could not load admin billing summary")
|
|
return
|
|
}
|
|
totals, err := s.adminBillingTotals(ctx)
|
|
if err != nil {
|
|
s.logDatabaseError("admin_billing_summary_totals", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "admin_billing_summary_failed", "could not load admin billing totals")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"contractVersion": "user-billing-admin-summary-v1",
|
|
"serviceId": serviceID,
|
|
"status": "ok",
|
|
"users": users,
|
|
"count": len(users),
|
|
"limit": limit,
|
|
"totals": totals,
|
|
"state": map[string]any{
|
|
"stateless": true,
|
|
"stateAuthority": s.config.StateAuthority,
|
|
"database": map[string]any{"authority": s.config.ExternalDatabaseLabel},
|
|
"redis": map[string]any{"role": "cache-only"},
|
|
},
|
|
"valuesRedacted": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAdminBillingPlans(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
plans, err := s.billingPlans(ctx)
|
|
if err != nil {
|
|
s.logDatabaseError("admin_billing_plans", err)
|
|
writeAPIError(w, http.StatusInternalServerError, "admin_billing_plans_failed", "could not load billing plans")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-plans-v1", "plans": plans, "count": len(plans), "limitSemantics": "zero-means-unlimited", "valuesRedacted": true})
|
|
}
|
|
|
|
var errInsufficientCredits = errors.New("insufficient credits")
|
|
|
|
type billingLimitError struct {
|
|
Code string
|
|
Message string
|
|
Status int
|
|
}
|
|
|
|
func (err billingLimitError) Error() string { return err.Message }
|
|
|
|
func (s *Server) reserveCredits(ctx context.Context, userID, apiKeyID, service string, credits, tokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
if idempotencyKey != "" {
|
|
var existingID, status string
|
|
var estimated int64
|
|
var resourceType string
|
|
err := tx.QueryRowContext(ctx, `SELECT id, status, estimated_credits, resource_type FROM hwlab_billing_reservations WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID, &status, &estimated, &resourceType)
|
|
if err == nil {
|
|
return map[string]any{"allowed": status == "reserved", "reservationId": existingID, "estimatedCredits": estimated, "resourceType": resourceType, "status": status, "idempotent": true}, tx.Commit()
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
}
|
|
var balance, reserved int64
|
|
var planID string
|
|
if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits, plan_id FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &reserved, &planID); err != nil {
|
|
return nil, err
|
|
}
|
|
resourceType := resourceTypeForService(service)
|
|
entitlement, err := s.evaluateResourceEntitlement(ctx, tx, userID, planID, resourceType, credits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
available := balance - reserved
|
|
if available < credits {
|
|
return nil, errInsufficientCredits
|
|
}
|
|
reservationID := newID("res")
|
|
metadataJSON := jsonObject(metadata)
|
|
expiresAt := time.Now().UTC().Add(s.config.ReservationTTL)
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_billing_reservations (id, user_id, service_id, resource_type, estimated_credits, estimated_tokens, api_key_id, idempotency_key, metadata, expires_at) VALUES ($1, $2, $3, $4, $5, $6, nullif($7, ''), nullif($8, ''), $9::jsonb, $10)`, reservationID, userID, service, resourceType, credits, nonNegative(tokens), apiKeyID, idempotencyKey, metadataJSON, expiresAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET reserved_credits = reserved_credits + $2, updated_at = now() WHERE user_id = $1`, userID, credits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"allowed": true, "reservationId": reservationID, "estimatedCredits": credits, "estimatedTokens": tokens, "resourceType": resourceType, "planId": planID, "entitlement": entitlement, "availableBefore": available, "expiresAt": expiresAt}, nil
|
|
}
|
|
|
|
func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID, token, apiKey, userID, serviceID string, usedCredits, usedTokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
if idempotencyKey != "" {
|
|
var existingID string
|
|
err := tx.QueryRowContext(ctx, `SELECT id FROM hwlab_usage_records WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
|
|
if err == nil {
|
|
return map[string]any{"recordId": existingID, "idempotent": true}, tx.Commit()
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
}
|
|
var reserved int64
|
|
apiKeyID := ""
|
|
resourceType := ""
|
|
if reservationID != "" {
|
|
var status string
|
|
err := tx.QueryRowContext(ctx, `SELECT user_id, service_id, resource_type, estimated_credits, status, COALESCE(api_key_id, '') FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &serviceID, &resourceType, &reserved, &status, &apiKeyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status != "reserved" {
|
|
return nil, fmt.Errorf("reservation status is %s", status)
|
|
}
|
|
} else {
|
|
principal, err := s.principalForBilling(ctx, r, token, apiKey, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
userID = principal.UserID
|
|
apiKeyID = principal.KeyID
|
|
serviceID = strings.TrimSpace(serviceID)
|
|
resourceType = resourceTypeForService(serviceID)
|
|
}
|
|
if serviceID == "" {
|
|
return nil, errors.New("serviceId is required")
|
|
}
|
|
if resourceType == "" {
|
|
resourceType = resourceTypeForService(serviceID)
|
|
}
|
|
credits := s.estimateCredits(usedCredits, usedTokens)
|
|
var balance, accountReserved int64
|
|
var planID string
|
|
if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits, plan_id FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &accountReserved, &planID); err != nil {
|
|
return nil, err
|
|
}
|
|
if reservationID == "" {
|
|
if _, err := s.evaluateResourceEntitlement(ctx, tx, userID, planID, resourceType, credits); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if reservationID == "" && balance-accountReserved < credits {
|
|
return nil, errInsufficientCredits
|
|
}
|
|
newReserved := accountReserved - reserved
|
|
if newReserved < 0 {
|
|
newReserved = 0
|
|
}
|
|
newBalance := balance - credits
|
|
if newBalance < 0 {
|
|
return nil, errInsufficientCredits
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET balance_credits = $2, reserved_credits = $3, updated_at = now() WHERE user_id = $1`, userID, newBalance, newReserved)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
recordID := newID("use")
|
|
metadataJSON := jsonObject(metadata)
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_usage_records (id, reservation_id, user_id, service_id, resource_type, api_key_id, quantity, credits, status, idempotency_key, metadata) VALUES ($1, nullif($2, ''), $3, $4, $5, nullif($6, ''), $7, $8, 'recorded', nullif($9, ''), $10::jsonb)`, recordID, reservationID, userID, serviceID, resourceType, apiKeyID, nonNegative(usedTokens), credits, idempotencyKey, metadataJSON)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
usageLedgerKey := ""
|
|
if idempotencyKey != "" {
|
|
usageLedgerKey = "usage:" + idempotencyKey
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, idempotency_key, metadata) VALUES ($1, $2, $3, $4, $5, 'usage', $6, 'usage', 'applied', nullif($7, ''), $8::jsonb)`, newID("led"), userID, -credits, balance, newBalance, serviceID, usageLedgerKey, metadataJSON)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if reservationID != "" {
|
|
_, err = tx.ExecContext(ctx, `UPDATE hwlab_billing_reservations SET status = 'completed', updated_at = now() WHERE id = $1`, reservationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"recordId": recordID, "userId": userID, "serviceId": serviceID, "resourceType": resourceType, "credits": credits, "balance": newBalance}, nil
|
|
}
|
|
|
|
func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceID, reason, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
|
|
reservationID = strings.TrimSpace(reservationID)
|
|
if reservationID == "" {
|
|
return nil, errors.New("reservationId is required")
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
var userID, actualServiceID, resourceType, status string
|
|
var reserved int64
|
|
err = tx.QueryRowContext(ctx, `SELECT user_id, service_id, resource_type, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &actualServiceID, &resourceType, &reserved, &status)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
serviceID = strings.TrimSpace(serviceID)
|
|
if serviceID != "" && serviceID != actualServiceID {
|
|
return nil, fmt.Errorf("reservation service is %s", actualServiceID)
|
|
}
|
|
if status == "cancelled" || status == "expired" {
|
|
return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "resourceType": resourceType, "releasedCredits": int64(0), "status": status, "idempotent": true}, tx.Commit()
|
|
}
|
|
if status != "reserved" {
|
|
return nil, fmt.Errorf("reservation status is %s", status)
|
|
}
|
|
var accountReserved int64
|
|
if err := tx.QueryRowContext(ctx, `SELECT reserved_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&accountReserved); err != nil {
|
|
return nil, err
|
|
}
|
|
newReserved := accountReserved - reserved
|
|
if newReserved < 0 {
|
|
newReserved = 0
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET reserved_credits = $2, updated_at = now() WHERE user_id = $1`, userID, newReserved)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
releaseMetadata := map[string]any{
|
|
"releaseReason": strings.TrimSpace(reason),
|
|
"releaseIdempotency": strings.TrimSpace(idempotencyKey),
|
|
"releaseValuesRedacted": true,
|
|
}
|
|
for key, value := range metadata {
|
|
releaseMetadata[key] = value
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE hwlab_billing_reservations SET status = 'cancelled', metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, updated_at = now() WHERE id = $1`, reservationID, jsonObject(releaseMetadata))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "resourceType": resourceType, "releasedCredits": reserved, "status": "cancelled"}, nil
|
|
}
|
|
|
|
func (s *Server) adjustCredits(ctx context.Context, userID string, delta int64, kind, reason, idempotencyKey string, metadata map[string]any) (creditAdjustResult, error) {
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
return creditAdjustResult{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
operatorUserID, operatorUsername, _ := operatorFields(metadata)
|
|
if idempotencyKey != "" {
|
|
var result creditAdjustResult
|
|
var before sql.NullInt64
|
|
err := tx.QueryRowContext(ctx, `SELECT id, user_id, COALESCE(balance_before, balance_after - delta_credits), balance_after, delta_credits, kind, reason, source, status FROM hwlab_credit_ledger WHERE idempotency_key = $1`, idempotencyKey).Scan(&result.LedgerID, &result.UserID, &before, &result.BalanceAfter, &result.DeltaCredits, &result.Kind, &result.Reason, &result.Source, &result.Status)
|
|
if err == nil {
|
|
if before.Valid {
|
|
result.BalanceBefore = before.Int64
|
|
}
|
|
result.IdempotencyKey = idempotencyKey
|
|
result.Idempotent = true
|
|
return result, tx.Commit()
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return creditAdjustResult{}, err
|
|
}
|
|
}
|
|
var balance int64
|
|
if err := tx.QueryRowContext(ctx, `SELECT balance_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance); err != nil {
|
|
return creditAdjustResult{}, err
|
|
}
|
|
newBalance := balance + delta
|
|
if newBalance < 0 {
|
|
return creditAdjustResult{}, errInsufficientCredits
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET balance_credits = $2, updated_at = now() WHERE user_id = $1`, userID, newBalance)
|
|
if err != nil {
|
|
return creditAdjustResult{}, err
|
|
}
|
|
ledgerID := newID("led")
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, idempotency_key, operator_user_id, operator_username, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, 'admin', 'applied', nullif($8, ''), nullif($9, ''), nullif($10, ''), $11::jsonb)`, ledgerID, userID, delta, balance, newBalance, kind, reason, idempotencyKey, operatorUserID, operatorUsername, jsonObject(metadata))
|
|
if err != nil {
|
|
return creditAdjustResult{}, err
|
|
}
|
|
result := creditAdjustResult{UserID: userID, BalanceBefore: balance, BalanceAfter: newBalance, DeltaCredits: delta, LedgerID: ledgerID, Kind: kind, Reason: reason, Source: "admin", Status: "applied", IdempotencyKey: idempotencyKey, Idempotent: false}
|
|
return result, tx.Commit()
|
|
}
|
|
|
|
type txQueryer interface {
|
|
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
|
}
|
|
|
|
func (s *Server) evaluateResourceEntitlement(ctx context.Context, tx txQueryer, userID, planID, resourceType string, requestedCredits int64) (resourceEntitlementSummary, error) {
|
|
plan, err := s.planByID(ctx, tx, planID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return resourceEntitlementSummary{}, billingLimitError{Code: "plan_not_found", Message: "billing plan is not active", Status: http.StatusForbidden}
|
|
}
|
|
return resourceEntitlementSummary{}, err
|
|
}
|
|
if plan.Status != "active" {
|
|
return resourceEntitlementSummary{}, billingLimitError{Code: "plan_disabled", Message: "billing plan is disabled", Status: http.StatusForbidden}
|
|
}
|
|
entitlements, err := s.resourceEntitlements(ctx, tx, planID, userID)
|
|
if err != nil {
|
|
return resourceEntitlementSummary{}, err
|
|
}
|
|
var entitlement resourceEntitlementSummary
|
|
found := false
|
|
for _, item := range entitlements {
|
|
if item.ResourceType == resourceType {
|
|
entitlement = item
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found || !entitlement.Enabled {
|
|
return resourceEntitlementSummary{}, billingLimitError{Code: "resource_not_entitled", Message: "resource is not enabled for this billing plan", Status: http.StatusForbidden}
|
|
}
|
|
if entitlement.MaxConcurrentReservations > 0 && entitlement.ActiveReservations >= entitlement.MaxConcurrentReservations {
|
|
return entitlement, billingLimitError{Code: "resource_concurrency_exceeded", Message: "resource concurrency limit exceeded", Status: http.StatusTooManyRequests}
|
|
}
|
|
if entitlement.RPMLimit > 0 && entitlement.RPMUsed >= entitlement.RPMLimit {
|
|
return entitlement, billingLimitError{Code: "resource_rpm_exceeded", Message: "resource RPM limit exceeded", Status: http.StatusTooManyRequests}
|
|
}
|
|
if entitlement.MonthlyQuotaCredits > 0 && entitlement.MonthlyUsageCredits+requestedCredits > entitlement.MonthlyQuotaCredits {
|
|
return entitlement, billingLimitError{Code: "resource_monthly_quota_exceeded", Message: "resource monthly quota exceeded", Status: http.StatusPaymentRequired}
|
|
}
|
|
return entitlement, nil
|
|
}
|
|
|
|
func (s *Server) planByID(ctx context.Context, queryer txQueryer, planID string) (billingPlanSummary, error) {
|
|
planID = strings.TrimSpace(planID)
|
|
if planID == "" {
|
|
planID = "default"
|
|
}
|
|
var plan billingPlanSummary
|
|
var metadataRaw string
|
|
err := queryer.QueryRowContext(ctx, `SELECT id, display_name, status, description, metadata::text FROM hwlab_billing_plans WHERE id = $1`, planID).Scan(&plan.ID, &plan.DisplayName, &plan.Status, &plan.Description, &metadataRaw)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
plan.Metadata = parseJSONMap(metadataRaw)
|
|
return plan, nil
|
|
}
|
|
|
|
func (s *Server) billingPlans(ctx context.Context) ([]billingPlanDetail, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, display_name, status, description, metadata::text FROM hwlab_billing_plans ORDER BY id ASC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
plans := []billingPlanDetail{}
|
|
for rows.Next() {
|
|
var detail billingPlanDetail
|
|
var metadataRaw string
|
|
if err := rows.Scan(&detail.Plan.ID, &detail.Plan.DisplayName, &detail.Plan.Status, &detail.Plan.Description, &metadataRaw); err != nil {
|
|
return nil, err
|
|
}
|
|
detail.Plan.Metadata = parseJSONMap(metadataRaw)
|
|
plans = append(plans, detail)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range plans {
|
|
entitlements, err := s.resourceEntitlements(ctx, s.db, plans[i].Plan.ID, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plans[i].Entitlements = entitlements
|
|
}
|
|
return plans, nil
|
|
}
|
|
|
|
func (s *Server) updateUserPlan(ctx context.Context, userID, planID string) (adminUserDetail, error) {
|
|
planID = strings.TrimSpace(planID)
|
|
if planID == "" {
|
|
return adminUserDetail{}, errors.New("planId is required")
|
|
}
|
|
if _, err := s.planByID(ctx, s.db, planID); err != nil {
|
|
return adminUserDetail{}, err
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET plan_id = $2, updated_at = now() WHERE user_id = $1`, userID, planID)
|
|
if err != nil {
|
|
return adminUserDetail{}, err
|
|
}
|
|
affected, _ := res.RowsAffected()
|
|
if affected == 0 {
|
|
return adminUserDetail{}, sql.ErrNoRows
|
|
}
|
|
return s.adminUserDetail(ctx, userID)
|
|
}
|
|
|
|
func (s *Server) resourceEntitlements(ctx context.Context, queryer txQueryer, planID, userID string) ([]resourceEntitlementSummary, error) {
|
|
rows, err := queryer.QueryContext(ctx, `
|
|
SELECT id, plan_id, resource_type, enabled, monthly_quota_credits, max_concurrent_reservations, rpm_limit, metadata::text
|
|
FROM hwlab_resource_entitlements
|
|
WHERE plan_id = $1
|
|
ORDER BY resource_type ASC`, planID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []resourceEntitlementSummary{}
|
|
for rows.Next() {
|
|
var item resourceEntitlementSummary
|
|
var metadataRaw string
|
|
if err := rows.Scan(&item.ID, &item.PlanID, &item.ResourceType, &item.Enabled, &item.MonthlyQuotaCredits, &item.MaxConcurrentReservations, &item.RPMLimit, &metadataRaw); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Metadata = parseJSONMap(metadataRaw)
|
|
item.LimitSemantics = "zero-means-unlimited"
|
|
item.MonthlyQuotaUnlimited = item.MonthlyQuotaCredits == 0
|
|
item.ConcurrencyUnlimited = item.MaxConcurrentReservations == 0
|
|
item.RPMUnlimited = item.RPMLimit == 0
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(userID) == "" || len(items) == 0 {
|
|
return items, nil
|
|
}
|
|
for i := range items {
|
|
if err := s.populateEntitlementUsage(ctx, queryer, userID, &items[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Server) populateEntitlementUsage(ctx context.Context, queryer txQueryer, userID string, item *resourceEntitlementSummary) error {
|
|
monthStart := time.Now().UTC().Format("2006-01-02")
|
|
if err := queryer.QueryRowContext(ctx, `SELECT COALESCE(SUM(credits), 0) FROM hwlab_usage_records WHERE user_id = $1 AND resource_type = $2 AND created_at >= date_trunc('month', $3::timestamptz)`, userID, item.ResourceType, monthStart).Scan(&item.MonthlyUsageCredits); err != nil {
|
|
return err
|
|
}
|
|
if err := queryer.QueryRowContext(ctx, `SELECT COUNT(*) FROM hwlab_billing_reservations WHERE user_id = $1 AND resource_type = $2 AND status = 'reserved' AND expires_at > now()`, userID, item.ResourceType).Scan(&item.ActiveReservations); err != nil {
|
|
return err
|
|
}
|
|
if err := queryer.QueryRowContext(ctx, `SELECT COUNT(*) FROM hwlab_billing_reservations WHERE user_id = $1 AND resource_type = $2 AND created_at >= now() - interval '1 minute'`, userID, item.ResourceType).Scan(&item.RPMUsed); err != nil {
|
|
return err
|
|
}
|
|
if item.MonthlyQuotaCredits > 0 {
|
|
item.MonthlyRemainingCredits = item.MonthlyQuotaCredits - item.MonthlyUsageCredits
|
|
if item.MonthlyRemainingCredits < 0 {
|
|
item.MonthlyRemainingCredits = 0
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resourceTypeForService(serviceID string) string {
|
|
value := strings.ToLower(strings.TrimSpace(serviceID))
|
|
switch {
|
|
case strings.Contains(value, "code-agent") || strings.Contains(value, "code_agent") || strings.Contains(value, "codex"):
|
|
return "code_agent"
|
|
case strings.Contains(value, "aipod") || strings.Contains(value, "ai-pod"):
|
|
return "aipod"
|
|
case strings.Contains(value, "hwpod") || strings.Contains(value, "hw-pod"):
|
|
return "hwpod"
|
|
case value != "":
|
|
return strings.Map(func(r rune) rune {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
|
|
return r
|
|
}
|
|
if r == '-' || r == '.' || r == '/' {
|
|
return '_'
|
|
}
|
|
return -1
|
|
}, value)
|
|
default:
|
|
return "generic"
|
|
}
|
|
}
|
|
|
|
func parseJSONMap(raw string) map[string]any {
|
|
result := map[string]any{}
|
|
_ = json.Unmarshal([]byte(raw), &result)
|
|
return result
|
|
}
|
|
|
|
func operatorFields(metadata map[string]any) (string, string, string) {
|
|
operator, _ := metadata["operator"].(map[string]any)
|
|
if operator == nil {
|
|
return "", "", ""
|
|
}
|
|
return stringFromAny(operator["id"]), stringFromAny(operator["username"]), stringFromAny(operator["role"])
|
|
}
|
|
|
|
func stringFromAny(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
|
|
func (s *Server) principalForBilling(ctx context.Context, r *http.Request, token, apiKey, userID string) (*Principal, error) {
|
|
if token == "" && apiKey == "" {
|
|
token = bearerToken(r)
|
|
}
|
|
if token != "" || apiKey != "" {
|
|
return s.principalFromToken(ctx, first(token, apiKey))
|
|
}
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
return nil, errors.New("billing subject is required")
|
|
}
|
|
var principal Principal
|
|
err := s.db.QueryRowContext(ctx, `SELECT id, email, username, role FROM hwlab_users WHERE id = $1 AND status = 'active'`, userID).Scan(&principal.UserID, &principal.Email, &principal.Username, &principal.Role)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
principal.AuthType = "internal-user-id"
|
|
principal.Scopes = []string{"api"}
|
|
return &principal, nil
|
|
}
|
|
|
|
func (s *Server) principalFromToken(ctx context.Context, token string) (*Principal, error) {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return nil, errors.New("empty token")
|
|
}
|
|
hash := tokenHash(token)
|
|
var principal Principal
|
|
if strings.HasPrefix(token, "hwl_") {
|
|
var scopesRaw string
|
|
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.email, u.username, u.role, k.id, k.scopes_json::text FROM hwlab_api_keys k JOIN hwlab_users u ON u.id = k.user_id WHERE k.key_hash = $1 AND k.status = 'active' AND k.revoked_at IS NULL AND u.status = 'active'`, hash).Scan(&principal.UserID, &principal.Email, &principal.Username, &principal.Role, &principal.KeyID, &scopesRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = json.Unmarshal([]byte(scopesRaw), &principal.Scopes)
|
|
if len(principal.Scopes) == 0 {
|
|
principal.Scopes = []string{"api"}
|
|
}
|
|
principal.AuthType = "api-key"
|
|
_, _ = s.db.ExecContext(ctx, `UPDATE hwlab_api_keys SET last_used_at = now() WHERE id = $1`, principal.KeyID)
|
|
return &principal, nil
|
|
}
|
|
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.email, u.username, u.role FROM hwlab_user_sessions s JOIN hwlab_users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() AND u.status = 'active'`, hash).Scan(&principal.UserID, &principal.Email, &principal.Username, &principal.Role)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
principal.AuthType = "session"
|
|
principal.Scopes = []string{"session"}
|
|
return &principal, nil
|
|
}
|
|
|
|
func (s *Server) requirePrincipal(w http.ResponseWriter, r *http.Request) (*Principal, bool) {
|
|
if !s.databaseAvailable(w) {
|
|
return nil, false
|
|
}
|
|
principal, err := s.principalFromToken(r.Context(), bearerToken(r))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusUnauthorized, "unauthorized", "valid bearer session or API key is required")
|
|
return nil, false
|
|
}
|
|
return principal, true
|
|
}
|
|
|
|
func (s *Server) internalAllowed(w http.ResponseWriter, r *http.Request) bool {
|
|
if s.config.InternalToken == "" {
|
|
if s.config.RequireInternalToken {
|
|
writeAPIError(w, http.StatusServiceUnavailable, "internal_token_missing", "internal token is required but not configured")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
candidate := strings.TrimSpace(r.Header.Get("X-HWLAB-Internal-Token"))
|
|
if candidate == "" {
|
|
candidate = bearerToken(r)
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(candidate), []byte(s.config.InternalToken)) != 1 {
|
|
writeAPIError(w, http.StatusUnauthorized, "internal_auth_failed", "valid internal token is required")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) databaseAvailable(w http.ResponseWriter) bool {
|
|
if s.db != nil {
|
|
return true
|
|
}
|
|
writeAPIError(w, http.StatusServiceUnavailable, "state_backend_not_configured", "HWLAB_USER_BILLING_DB_URL must point to the PK01 external PostgreSQL authority")
|
|
return false
|
|
}
|
|
|
|
func (s *Server) registrationAvailable(w http.ResponseWriter) bool {
|
|
if !s.RegistrationEnabled() {
|
|
writeAPIError(w, http.StatusForbidden, "registration_disabled", "registration is disabled")
|
|
return false
|
|
}
|
|
return s.databaseAvailable(w)
|
|
}
|
|
|
|
func (s *Server) RegistrationEnabled() bool {
|
|
return s.config.RegistrationEnabled
|
|
}
|
|
|
|
func (s *Server) readyWriteProbe(ctx context.Context) error {
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
probeID := newID("usr_probe")
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role) VALUES ($1, $2, $3, 'readiness probe', 'readiness-probe', 'pending', 'user')`, probeID, probeID+"@readiness.invalid", probeID)
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
return tx.Rollback()
|
|
}
|
|
|
|
func (s *Server) logDatabaseError(operation string, err error) {
|
|
log.Printf("%s database operation=%s errorClass=%s errorCode=%s valuesRedacted=true", serviceID, operation, classifyDatabaseError(err), databaseErrorCode(err))
|
|
}
|
|
|
|
func classifyDatabaseError(err error) string {
|
|
if err == nil {
|
|
return "none"
|
|
}
|
|
text := strings.ToLower(err.Error())
|
|
code := strings.ToUpper(databaseErrorCode(err))
|
|
if code == "57014" || errors.Is(err, context.DeadlineExceeded) || strings.Contains(text, "deadline exceeded") || strings.Contains(text, "timeout") {
|
|
return "timeout"
|
|
}
|
|
if strings.Contains(text, "certificate") || strings.Contains(text, "ssl") || strings.Contains(text, "tls") {
|
|
return "ssl"
|
|
}
|
|
if strings.Contains(text, "password") || strings.Contains(text, "authentication") || strings.Contains(text, "pg_hba") || code == "28P01" || strings.HasPrefix(code, "28") {
|
|
return "auth"
|
|
}
|
|
if strings.Contains(text, "too many connections") || strings.Contains(text, "connection refused") || strings.Contains(text, "connection reset") || strings.Contains(text, "broken pipe") {
|
|
return "connection"
|
|
}
|
|
return "database"
|
|
}
|
|
|
|
func databaseErrorCode(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
type sqlState interface{ SQLState() string }
|
|
var state sqlState
|
|
if errors.As(err, &state) {
|
|
return state.SQLState()
|
|
}
|
|
type coder interface{ Code() string }
|
|
var coded coder
|
|
if errors.As(err, &coded) {
|
|
return coded.Code()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) accountBalance(ctx context.Context, userID string) (int64, int64, error) {
|
|
var balance, reserved int64
|
|
err := s.db.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits FROM hwlab_credit_accounts WHERE user_id = $1`, userID).Scan(&balance, &reserved)
|
|
return balance, reserved, err
|
|
}
|
|
|
|
func (s *Server) accountPlanContext(ctx context.Context, userID string) (string, billingPlanSummary, []resourceEntitlementSummary, error) {
|
|
planID := "default"
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(plan_id, 'default') FROM hwlab_credit_accounts WHERE user_id = $1`, userID).Scan(&planID); err != nil {
|
|
return "", billingPlanSummary{}, nil, err
|
|
}
|
|
plan, err := s.planByID(ctx, s.db, planID)
|
|
if err != nil {
|
|
return planID, billingPlanSummary{}, nil, err
|
|
}
|
|
entitlements, err := s.resourceEntitlements(ctx, s.db, planID, userID)
|
|
return planID, plan, entitlements, err
|
|
}
|
|
|
|
func (s *Server) usageTotals(ctx context.Context, userID string) (int64, int64, int64, error) {
|
|
var credits, quantity, count int64
|
|
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*) FROM hwlab_usage_records WHERE user_id = $1`, userID).Scan(&credits, &quantity, &count)
|
|
return credits, quantity, count, err
|
|
}
|
|
|
|
func (s *Server) usageByService(ctx context.Context, userID string, limit int) ([]serviceUsageSummary, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT service_id, resource_type, COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*), MAX(created_at) FROM hwlab_usage_records WHERE user_id = $1 GROUP BY service_id, resource_type ORDER BY MAX(created_at) DESC NULLS LAST, service_id ASC LIMIT $2`, userID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []serviceUsageSummary{}
|
|
for rows.Next() {
|
|
var item serviceUsageSummary
|
|
if err := rows.Scan(&item.ServiceID, &item.ResourceType, &item.Credits, &item.Quantity, &item.RecordCount, &item.LastUsedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) ledgerRows(ctx context.Context, userID string, limit int) ([]ledgerSummaryRow, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, kind, reason, delta_credits, balance_before, balance_after, source, status, COALESCE(idempotency_key, ''), COALESCE(operator_user_id, ''), COALESCE(operator_username, ''), metadata::text, created_at FROM hwlab_credit_ledger WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2`, userID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ledgerSummaryRow{}
|
|
for rows.Next() {
|
|
var item ledgerSummaryRow
|
|
var before sql.NullInt64
|
|
var metadataRaw string
|
|
if err := rows.Scan(&item.ID, &item.Kind, &item.Reason, &item.DeltaCredits, &before, &item.BalanceAfter, &item.Source, &item.Status, &item.IdempotencyKey, &item.OperatorUserID, &item.OperatorUsername, &metadataRaw, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if before.Valid {
|
|
value := before.Int64
|
|
item.BalanceBefore = &value
|
|
}
|
|
item.Metadata = parseJSONMap(metadataRaw)
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) activeReservations(ctx context.Context, userID string, limit int) ([]reservationSummaryRow, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, service_id, resource_type, estimated_credits, estimated_tokens, status, expires_at, created_at FROM hwlab_billing_reservations WHERE user_id = $1 AND status = 'reserved' AND expires_at > now() ORDER BY expires_at ASC, created_at DESC LIMIT $2`, userID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []reservationSummaryRow{}
|
|
for rows.Next() {
|
|
var item reservationSummaryRow
|
|
if err := rows.Scan(&item.ReservationID, &item.ServiceID, &item.ResourceType, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) adminBillingUsers(ctx context.Context, limit int) ([]adminBillingUserSummary, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT
|
|
u.id,
|
|
u.email,
|
|
u.username,
|
|
u.display_name,
|
|
u.status,
|
|
u.role,
|
|
u.email_verified,
|
|
u.created_at,
|
|
COALESCE(a.plan_id, 'default'),
|
|
COALESCE(a.balance_credits, 0),
|
|
COALESCE(a.reserved_credits, 0),
|
|
COALESCE(usage.total_credits, 0),
|
|
COALESCE(usage.total_quantity, 0),
|
|
COALESCE(usage.record_count, 0),
|
|
usage.last_used_at,
|
|
COALESCE(keys.active_count, 0),
|
|
COALESCE(keys.total_count, 0),
|
|
keys.last_key_used_at,
|
|
COALESCE(res.active_count, 0)
|
|
FROM hwlab_users u
|
|
LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id
|
|
LEFT JOIN (
|
|
SELECT user_id, SUM(credits) AS total_credits, SUM(quantity) AS total_quantity, COUNT(*) AS record_count, MAX(created_at) AS last_used_at
|
|
FROM hwlab_usage_records
|
|
GROUP BY user_id
|
|
) usage ON usage.user_id = u.id
|
|
LEFT JOIN (
|
|
SELECT user_id, COUNT(*) FILTER (WHERE status = 'active' AND revoked_at IS NULL) AS active_count, COUNT(*) AS total_count, MAX(last_used_at) AS last_key_used_at
|
|
FROM hwlab_api_keys
|
|
GROUP BY user_id
|
|
) keys ON keys.user_id = u.id
|
|
LEFT JOIN (
|
|
SELECT user_id, COUNT(*) AS active_count
|
|
FROM hwlab_billing_reservations
|
|
WHERE status = 'reserved' AND expires_at > now()
|
|
GROUP BY user_id
|
|
) res ON res.user_id = u.id
|
|
ORDER BY usage.last_used_at DESC NULLS LAST, u.created_at DESC, u.username ASC
|
|
LIMIT $1`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := []adminBillingUserSummary{}
|
|
userIDs := []string{}
|
|
for rows.Next() {
|
|
var item adminBillingUserSummary
|
|
var lastUsed sql.NullTime
|
|
var lastKeyUsed sql.NullTime
|
|
if err := rows.Scan(&item.User.ID, &item.User.Email, &item.User.Username, &item.User.DisplayName, &item.User.Status, &item.User.Role, &item.User.EmailVerified, &item.User.CreatedAt, &item.PlanID, &item.Credits.Balance, &item.Credits.Reserved, &item.Usage.TotalCredits, &item.Usage.TotalQuantity, &item.Usage.RecordCount, &lastUsed, &item.APIKeys.ActiveCount, &item.APIKeys.TotalCount, &lastKeyUsed, &item.Reservations.ActiveCount); err != nil {
|
|
return nil, err
|
|
}
|
|
if lastUsed.Valid {
|
|
value := lastUsed.Time
|
|
item.Usage.LastUsedAt = &value
|
|
}
|
|
if lastKeyUsed.Valid {
|
|
value := lastKeyUsed.Time
|
|
item.APIKeys.LastUsedAt = &value
|
|
}
|
|
item.Credits.Available = item.Credits.Balance - item.Credits.Reserved
|
|
userIDs = append(userIDs, item.User.ID)
|
|
users = append(users, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
reservations, err := s.activeReservationsForUsers(ctx, userIDs, 5)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range users {
|
|
users[i].Reservations.Active = reservations[users[i].User.ID]
|
|
}
|
|
if err := s.enrichAdminBillingUsers(ctx, users); err != nil {
|
|
return nil, err
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
func (s *Server) activeReservationsForUsers(ctx context.Context, userIDs []string, perUserLimit int) (map[string][]reservationSummaryRow, error) {
|
|
items := map[string][]reservationSummaryRow{}
|
|
if len(userIDs) == 0 {
|
|
return items, nil
|
|
}
|
|
placeholders := make([]string, len(userIDs))
|
|
args := make([]any, 0, len(userIDs)+1)
|
|
for i, userID := range userIDs {
|
|
placeholders[i] = "$" + strconv.Itoa(i+1)
|
|
args = append(args, userID)
|
|
}
|
|
limitParam := "$" + strconv.Itoa(len(args)+1)
|
|
args = append(args, perUserLimit)
|
|
query := `
|
|
SELECT user_id, id, service_id, resource_type, estimated_credits, estimated_tokens, status, expires_at, created_at
|
|
FROM (
|
|
SELECT user_id, id, service_id, resource_type, estimated_credits, estimated_tokens, status, expires_at, created_at,
|
|
row_number() OVER (PARTITION BY user_id ORDER BY expires_at ASC, created_at DESC) AS rn
|
|
FROM hwlab_billing_reservations
|
|
WHERE user_id IN (` + strings.Join(placeholders, ", ") + `) AND status = 'reserved' AND expires_at > now()
|
|
) ranked
|
|
WHERE rn <= ` + limitParam + `
|
|
ORDER BY expires_at ASC, created_at DESC`
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var userID string
|
|
var item reservationSummaryRow
|
|
if err := rows.Scan(&userID, &item.ReservationID, &item.ServiceID, &item.ResourceType, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items[userID] = append(items[userID], item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) adminBillingTotals(ctx context.Context) (map[string]any, error) {
|
|
var totals struct {
|
|
Users int64
|
|
ActiveUsers int64
|
|
DisabledUsers int64
|
|
PendingUsers int64
|
|
BalanceCredits int64
|
|
ReservedCredits int64
|
|
AvailableCredits int64
|
|
UsageCredits int64
|
|
UsageQuantity int64
|
|
UsageRecords int64
|
|
ActiveReservations int64
|
|
}
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT
|
|
(SELECT COUNT(*) FROM hwlab_users),
|
|
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'active'),
|
|
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'disabled'),
|
|
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'pending'),
|
|
COALESCE((SELECT SUM(balance_credits) FROM hwlab_credit_accounts), 0),
|
|
COALESCE((SELECT SUM(reserved_credits) FROM hwlab_credit_accounts), 0),
|
|
COALESCE((SELECT SUM(balance_credits - reserved_credits) FROM hwlab_credit_accounts), 0),
|
|
COALESCE((SELECT SUM(credits) FROM hwlab_usage_records), 0),
|
|
COALESCE((SELECT SUM(quantity) FROM hwlab_usage_records), 0),
|
|
(SELECT COUNT(*) FROM hwlab_usage_records),
|
|
(SELECT COUNT(*) FROM hwlab_billing_reservations WHERE status = 'reserved' AND expires_at > now())`).Scan(&totals.Users, &totals.ActiveUsers, &totals.DisabledUsers, &totals.PendingUsers, &totals.BalanceCredits, &totals.ReservedCredits, &totals.AvailableCredits, &totals.UsageCredits, &totals.UsageQuantity, &totals.UsageRecords, &totals.ActiveReservations)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"users": totals.Users,
|
|
"activeUsers": totals.ActiveUsers,
|
|
"disabledUsers": totals.DisabledUsers,
|
|
"pendingUsers": totals.PendingUsers,
|
|
"balanceCredits": totals.BalanceCredits,
|
|
"reservedCredits": totals.ReservedCredits,
|
|
"availableCredits": totals.AvailableCredits,
|
|
"usageCredits": totals.UsageCredits,
|
|
"usageQuantity": totals.UsageQuantity,
|
|
"usageRecords": totals.UsageRecords,
|
|
"activeReservations": totals.ActiveReservations,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) apiKeysForUser(ctx context.Context, userID string) ([]apiKeySummary, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, key_prefix, scopes_json::text, status, created_at, last_used_at, revoked_at FROM hwlab_api_keys WHERE user_id = $1 ORDER BY created_at DESC, id DESC`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
keys := []apiKeySummary{}
|
|
for rows.Next() {
|
|
key, err := scanAPIKey(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
return keys, rows.Err()
|
|
}
|
|
|
|
func (s *Server) apiKeyForUser(ctx context.Context, userID, keyID string) (apiKeySummary, error) {
|
|
return scanAPIKey(s.db.QueryRowContext(ctx, `SELECT id, name, key_prefix, scopes_json::text, status, created_at, last_used_at, revoked_at FROM hwlab_api_keys WHERE user_id = $1 AND id = $2`, userID, keyID))
|
|
}
|
|
|
|
type apiKeyScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanAPIKey(scanner apiKeyScanner) (apiKeySummary, error) {
|
|
var key apiKeySummary
|
|
var scopesRaw string
|
|
var lastUsed sql.NullTime
|
|
var revoked sql.NullTime
|
|
if err := scanner.Scan(&key.ID, &key.Name, &key.Prefix, &scopesRaw, &key.Status, &key.CreatedAt, &lastUsed, &revoked); err != nil {
|
|
return key, err
|
|
}
|
|
_ = json.Unmarshal([]byte(scopesRaw), &key.Scopes)
|
|
if len(key.Scopes) == 0 {
|
|
key.Scopes = []string{"api"}
|
|
}
|
|
if lastUsed.Valid {
|
|
value := lastUsed.Time
|
|
key.LastUsedAt = &value
|
|
}
|
|
if revoked.Valid {
|
|
value := revoked.Time
|
|
key.RevokedAt = &value
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func adminUsersQueryFromRequest(r *http.Request) adminUsersQuery {
|
|
page := queryLimit(r, "page", 1, 100000)
|
|
pageSize := queryLimit(r, "pageSize", queryLimit(r, "limit", 50, 100), 100)
|
|
status := normalizeUserStatus(r.URL.Query().Get("status"), "")
|
|
role := normalizeRole(r.URL.Query().Get("role"), "")
|
|
return adminUsersQuery{
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
Search: strings.TrimSpace(r.URL.Query().Get("search")),
|
|
Status: status,
|
|
Role: role,
|
|
}
|
|
}
|
|
|
|
func (s *Server) adminUsers(ctx context.Context, query adminUsersQuery) ([]adminBillingUserSummary, int64, error) {
|
|
where, args := adminUsersWhere(query)
|
|
var total int64
|
|
countQuery := `SELECT COUNT(*) FROM hwlab_users u ` + where
|
|
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
offset := (query.Page - 1) * query.PageSize
|
|
args = append(args, query.PageSize, offset)
|
|
limitParam := "$" + strconv.Itoa(len(args)-1)
|
|
offsetParam := "$" + strconv.Itoa(len(args))
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT
|
|
u.id,
|
|
u.email,
|
|
u.username,
|
|
u.display_name,
|
|
u.status,
|
|
u.role,
|
|
u.email_verified,
|
|
u.created_at,
|
|
COALESCE(a.plan_id, 'default'),
|
|
COALESCE(a.balance_credits, 0),
|
|
COALESCE(a.reserved_credits, 0),
|
|
COALESCE(usage.total_credits, 0),
|
|
COALESCE(usage.total_quantity, 0),
|
|
COALESCE(usage.record_count, 0),
|
|
usage.last_used_at,
|
|
COALESCE(keys.active_count, 0),
|
|
COALESCE(keys.total_count, 0),
|
|
keys.last_key_used_at,
|
|
COALESCE(res.active_count, 0)
|
|
FROM hwlab_users u
|
|
LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id
|
|
LEFT JOIN (
|
|
SELECT user_id, SUM(credits) AS total_credits, SUM(quantity) AS total_quantity, COUNT(*) AS record_count, MAX(created_at) AS last_used_at
|
|
FROM hwlab_usage_records
|
|
GROUP BY user_id
|
|
) usage ON usage.user_id = u.id
|
|
LEFT JOIN (
|
|
SELECT user_id, COUNT(*) FILTER (WHERE status = 'active' AND revoked_at IS NULL) AS active_count, COUNT(*) AS total_count, MAX(last_used_at) AS last_key_used_at
|
|
FROM hwlab_api_keys
|
|
GROUP BY user_id
|
|
) keys ON keys.user_id = u.id
|
|
LEFT JOIN (
|
|
SELECT user_id, COUNT(*) AS active_count
|
|
FROM hwlab_billing_reservations
|
|
WHERE status = 'reserved' AND expires_at > now()
|
|
GROUP BY user_id
|
|
) res ON res.user_id = u.id
|
|
`+where+`
|
|
ORDER BY usage.last_used_at DESC NULLS LAST, u.created_at DESC, u.username ASC
|
|
LIMIT `+limitParam+` OFFSET `+offsetParam, args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
users, userIDs, err := scanAdminBillingUsers(rows)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
reservations, err := s.activeReservationsForUsers(ctx, userIDs, 5)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
for i := range users {
|
|
users[i].Reservations.Active = reservations[users[i].User.ID]
|
|
}
|
|
if err := s.enrichAdminBillingUsers(ctx, users); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return users, total, nil
|
|
}
|
|
|
|
func (s *Server) enrichAdminBillingUsers(ctx context.Context, users []adminBillingUserSummary) error {
|
|
for i := range users {
|
|
plan, err := s.planByID(ctx, s.db, users[i].PlanID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entitlements, err := s.resourceEntitlements(ctx, s.db, users[i].PlanID, users[i].User.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
users[i].Plan = plan
|
|
users[i].Entitlements = entitlements
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func adminUsersWhere(query adminUsersQuery) (string, []any) {
|
|
clauses := []string{}
|
|
args := []any{}
|
|
if query.Status != "" {
|
|
args = append(args, query.Status)
|
|
clauses = append(clauses, "u.status = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if query.Role != "" {
|
|
args = append(args, query.Role)
|
|
clauses = append(clauses, "u.role = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if query.Search != "" {
|
|
args = append(args, "%"+strings.ToLower(query.Search)+"%")
|
|
param := "$" + strconv.Itoa(len(args))
|
|
clauses = append(clauses, "(lower(u.id) LIKE "+param+" OR lower(u.email) LIKE "+param+" OR lower(u.username) LIKE "+param+" OR lower(u.display_name) LIKE "+param+")")
|
|
}
|
|
if len(clauses) == 0 {
|
|
return "", args
|
|
}
|
|
return "WHERE " + strings.Join(clauses, " AND "), args
|
|
}
|
|
|
|
func (s *Server) adminUserDetail(ctx context.Context, userID string) (adminUserDetail, error) {
|
|
users, _, err := s.adminUsers(ctx, adminUsersQuery{Page: 1, PageSize: 1, Search: userID})
|
|
if err != nil {
|
|
return adminUserDetail{}, err
|
|
}
|
|
var summary adminBillingUserSummary
|
|
found := false
|
|
for _, user := range users {
|
|
if user.User.ID == userID {
|
|
summary = user
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return adminUserDetail{}, sql.ErrNoRows
|
|
}
|
|
ledger, err := s.ledgerRows(ctx, userID, 50)
|
|
if err != nil {
|
|
return adminUserDetail{}, err
|
|
}
|
|
keys, err := s.apiKeysForUser(ctx, userID)
|
|
if err != nil {
|
|
return adminUserDetail{}, err
|
|
}
|
|
usageBy, err := s.usageByService(ctx, userID, 50)
|
|
if err != nil {
|
|
return adminUserDetail{}, err
|
|
}
|
|
reservations, err := s.activeReservations(ctx, userID, 50)
|
|
if err != nil {
|
|
return adminUserDetail{}, err
|
|
}
|
|
return adminUserDetail{Summary: summary, Ledger: ledger, APIKeys: keys, UsageBy: usageBy, Reservations: reservations}, nil
|
|
}
|
|
|
|
func scanAdminBillingUsers(rows *sql.Rows) ([]adminBillingUserSummary, []string, error) {
|
|
users := []adminBillingUserSummary{}
|
|
userIDs := []string{}
|
|
for rows.Next() {
|
|
var item adminBillingUserSummary
|
|
var lastUsed sql.NullTime
|
|
var lastKeyUsed sql.NullTime
|
|
if err := rows.Scan(&item.User.ID, &item.User.Email, &item.User.Username, &item.User.DisplayName, &item.User.Status, &item.User.Role, &item.User.EmailVerified, &item.User.CreatedAt, &item.PlanID, &item.Credits.Balance, &item.Credits.Reserved, &item.Usage.TotalCredits, &item.Usage.TotalQuantity, &item.Usage.RecordCount, &lastUsed, &item.APIKeys.ActiveCount, &item.APIKeys.TotalCount, &lastKeyUsed, &item.Reservations.ActiveCount); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if lastUsed.Valid {
|
|
value := lastUsed.Time
|
|
item.Usage.LastUsedAt = &value
|
|
}
|
|
if lastKeyUsed.Valid {
|
|
value := lastKeyUsed.Time
|
|
item.APIKeys.LastUsedAt = &value
|
|
}
|
|
item.Credits.Available = item.Credits.Balance - item.Credits.Reserved
|
|
userIDs = append(userIDs, item.User.ID)
|
|
users = append(users, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return users, userIDs, nil
|
|
}
|
|
|
|
func normalizeRole(value, fallback string) string {
|
|
role := strings.TrimSpace(strings.ToLower(value))
|
|
if role == "" {
|
|
return fallback
|
|
}
|
|
if role == "user" || role == "admin" {
|
|
return role
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeUserStatus(value, fallback string) string {
|
|
status := strings.TrimSpace(strings.ToLower(value))
|
|
if status == "" {
|
|
return fallback
|
|
}
|
|
if status == "active" || status == "disabled" || status == "pending" {
|
|
return status
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func queryLimit(r *http.Request, name string, fallback, max int) int {
|
|
value := strings.TrimSpace(r.URL.Query().Get(name))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
if parsed > max {
|
|
return max
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func (s *Server) estimateCredits(explicit, tokens int64) int64 {
|
|
if explicit > 0 {
|
|
return explicit
|
|
}
|
|
if tokens <= 0 {
|
|
return 1
|
|
}
|
|
per := s.config.CreditPerThousandTok
|
|
if per <= 0 {
|
|
per = 1
|
|
}
|
|
return int64(math.Ceil(float64(tokens*per) / 1000.0))
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
|
defer r.Body.Close()
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_json", "request body must be valid JSON")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
|
|
writeJSON(w, status, map[string]any{"error": apiError{Code: code, Message: message}})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func bearerToken(r *http.Request) string {
|
|
value := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
|
return strings.TrimSpace(value[7:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeUsername(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
value = strings.Map(func(r rune) rune {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
|
return r
|
|
}
|
|
return -1
|
|
}, value)
|
|
if len(value) < 3 || len(value) > 64 {
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
|
|
func hashPassword(password string) (string, error) {
|
|
salt, err := randomBytes(16)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
iterations := 210000
|
|
digest := pbkdf2SHA256([]byte(password), salt, iterations, 32)
|
|
return fmt.Sprintf("pbkdf2_sha256$%d$%s$%s", iterations, b64(salt), b64(digest)), nil
|
|
}
|
|
|
|
func verifyPassword(encoded, password string) bool {
|
|
if strings.HasPrefix(encoded, "sha256:") {
|
|
parts := strings.Split(encoded, ":")
|
|
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
|
return false
|
|
}
|
|
digest := sha256.Sum256([]byte(parts[1] + ":" + password))
|
|
actual := hex.EncodeToString(digest[:])
|
|
return subtle.ConstantTimeCompare([]byte(actual), []byte(parts[2])) == 1
|
|
}
|
|
parts := strings.Split(encoded, "$")
|
|
if len(parts) != 4 || parts[0] != "pbkdf2_sha256" {
|
|
return false
|
|
}
|
|
iterations, err := strconv.Atoi(parts[1])
|
|
if err != nil || iterations <= 0 {
|
|
return false
|
|
}
|
|
salt, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
expected, err := base64.RawURLEncoding.DecodeString(parts[3])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected))
|
|
return subtle.ConstantTimeCompare(actual, expected) == 1
|
|
}
|
|
|
|
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
|
|
hLen := sha256.Size
|
|
numBlocks := (keyLen + hLen - 1) / hLen
|
|
var output []byte
|
|
for block := 1; block <= numBlocks; block++ {
|
|
u := pbkdf2Block(password, salt, block)
|
|
t := append([]byte(nil), u...)
|
|
for i := 1; i < iterations; i++ {
|
|
u = hmacSHA256(password, u)
|
|
for j := range t {
|
|
t[j] ^= u[j]
|
|
}
|
|
}
|
|
output = append(output, t...)
|
|
}
|
|
return output[:keyLen]
|
|
}
|
|
|
|
func pbkdf2Block(password, salt []byte, block int) []byte {
|
|
buf := make([]byte, len(salt)+4)
|
|
copy(buf, salt)
|
|
binary.BigEndian.PutUint32(buf[len(salt):], uint32(block))
|
|
return hmacSHA256(password, buf)
|
|
}
|
|
|
|
func hmacSHA256(key, data []byte) []byte {
|
|
h := hmac.New(sha256.New, key)
|
|
_, _ = h.Write(data)
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
func tokenHash(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func newID(prefix string) string {
|
|
return prefix + "_" + randomToken(16)
|
|
}
|
|
|
|
func randomToken(size int) string {
|
|
buf, err := randomBytes(size)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b64(buf)
|
|
}
|
|
|
|
func randomBytes(size int) ([]byte, error) {
|
|
buf := make([]byte, size)
|
|
_, err := rand.Read(buf)
|
|
return buf, err
|
|
}
|
|
|
|
func b64(value []byte) string {
|
|
return base64.RawURLEncoding.EncodeToString(value)
|
|
}
|
|
|
|
func jsonObject(value map[string]any) string {
|
|
if value == nil {
|
|
return "{}"
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
func parseInt64(value string, fallback int64) int64 {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func envInt(name string, fallback int) int {
|
|
value := strings.TrimSpace(os.Getenv(name))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseDurationHours(value string, fallbackHours int64) time.Duration {
|
|
return time.Duration(parseInt64(value, fallbackHours)) * time.Hour
|
|
}
|
|
|
|
func parseDurationMinutes(value string, fallbackMinutes int64) time.Duration {
|
|
return time.Duration(parseInt64(value, fallbackMinutes)) * time.Minute
|
|
}
|
|
|
|
func parseDurationSeconds(value string, fallbackSeconds int64) time.Duration {
|
|
return time.Duration(parseInt64(value, fallbackSeconds)) * time.Second
|
|
}
|
|
|
|
func durationOrDefault(value time.Duration, fallback time.Duration) time.Duration {
|
|
if value <= 0 {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func first(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nonNegative(value int64) int64 {
|
|
if value < 0 {
|
|
return 0
|
|
}
|
|
return value
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func isDuplicate(err error) bool {
|
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "duplicate")
|
|
}
|