Files
pikasTech-HWLAB/internal/userbilling/service.go
T
2026-06-20 08:37:56 +08:00

2881 lines
106 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
StateAuthority string
RuntimeNamespace string
ExternalDatabaseLabel 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",
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"),
}
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(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
server.db = db
if cfg.MigrateOnStart {
migrateCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if err := server.migrate(migrateCtx); err != nil {
return nil, fmt.Errorf("migrate user billing schema: %w", err)
}
}
}
server.routes()
return server, nil
}
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(&currentHash); 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 {
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 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 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")
}