package adminapi import ( "bytes" "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "io" "log/slog" "net" "net/http" "strings" "time" "aigw/internal/adminui" "aigw/internal/apierror" "aigw/internal/billing" "aigw/internal/controlplane" "github.com/go-webauthn/webauthn/webauthn" "github.com/jackc/pgx/v5/pgconn" ) type API struct { store *controlplane.Store manager *controlplane.Manager billing *billing.Service token []byte logger *slog.Logger prefix string registrationEnabled bool sessionTTL time.Duration currency string publicURL string webauthn *webauthn.WebAuthn mailEnabled bool } type actorKey struct{} type auditWriter struct { http.ResponseWriter status int } func (w *auditWriter) WriteHeader(status int) { w.status = status w.ResponseWriter.WriteHeader(status) } func (w *auditWriter) Write(body []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK } return w.ResponseWriter.Write(body) } type Options struct { Store *controlplane.Store Manager *controlplane.Manager Billing *billing.Service Token string Logger *slog.Logger Prefix string RegistrationEnabled bool SessionTTL time.Duration Currency string PublicURL string WebAuthn *webauthn.WebAuthn MailEnabled bool } func New(options Options) *API { prefix := strings.TrimRight(options.Prefix, "/") if prefix == "" { prefix = "/admin" } if options.Logger == nil { options.Logger = slog.Default() } if options.SessionTTL <= 0 { options.SessionTTL = 12 * time.Hour } if options.Currency == "" { options.Currency = "usd" } return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token), logger: options.Logger, prefix: prefix, registrationEnabled: options.RegistrationEnabled, sessionTTL: options.SessionTTL, currency: options.Currency, publicURL: strings.TrimRight(options.PublicURL, "/") + "/", webauthn: options.WebAuthn, mailEnabled: options.MailEnabled} } func (a *API) Handler() http.Handler { mux := http.NewServeMux() apiPrefix := a.prefix + "/api" mux.HandleFunc("GET "+a.prefix, func(w http.ResponseWriter, r *http.Request) { target := a.prefix + "/" if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, http.StatusTemporaryRedirect) }) mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler())) mux.HandleFunc("GET "+apiPrefix+"/auth/config", a.public(a.authConfig)) mux.HandleFunc("GET "+apiPrefix+"/auth/session", a.public(a.authSession)) mux.HandleFunc("POST "+apiPrefix+"/auth/register", a.public(a.register)) mux.HandleFunc("POST "+apiPrefix+"/auth/login", a.public(a.login)) mux.HandleFunc("POST "+apiPrefix+"/auth/email/verify", a.public(a.verifyEmail)) mux.HandleFunc("POST "+apiPrefix+"/auth/email/resend", a.public(a.resendVerification)) mux.HandleFunc("POST "+apiPrefix+"/auth/password-reset/request", a.public(a.requestPasswordReset)) mux.HandleFunc("POST "+apiPrefix+"/auth/password-reset/complete", a.public(a.completePasswordReset)) mux.HandleFunc("POST "+apiPrefix+"/auth/invite/accept", a.public(a.acceptInvite)) mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp", a.public(a.completeMFA)) mux.HandleFunc("POST "+apiPrefix+"/auth/passkey/login/options", a.public(a.passkeyLoginOptions)) mux.HandleFunc("POST "+apiPrefix+"/auth/passkey/login", a.public(a.passkeyLogin)) mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/login/options", a.public(a.beginPasskeyMFA)) mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/login", a.public(a.finishPasskeyMFA)) mux.HandleFunc("POST "+apiPrefix+"/auth/logout", a.withAuth("overview.read", a.logout)) mux.HandleFunc("POST "+apiPrefix+"/auth/password", a.withAuth("overview.read", a.changePassword)) mux.HandleFunc("GET "+apiPrefix+"/auth/mfa", a.withAuth("overview.read", a.mfaStatus)) mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/begin", a.withAuth("overview.read", a.beginTOTP)) mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/confirm", a.withAuth("overview.read", a.confirmTOTP)) mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/disable", a.withAuth("overview.read", a.disableTOTP)) mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/options", a.withAuth("overview.read", a.beginPasskeyRegistration)) mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey", a.withAuth("overview.read", a.finishPasskeyRegistration)) mux.HandleFunc("GET "+apiPrefix+"/auth/sessions", a.withAuth("overview.read", a.listSessions)) mux.HandleFunc("POST "+apiPrefix+"/auth/sessions/{id}/revoke", a.withAuth("overview.read", a.revokeSession)) mux.HandleFunc("POST "+apiPrefix+"/auth/sessions/revoke-others", a.withAuth("overview.read", a.revokeOtherSessions)) mux.HandleFunc("POST "+apiPrefix+"/auth/passkeys/{id}/delete", a.withAuth("overview.read", a.deletePasskey)) mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth("overview.read", a.overview)) mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth("tenants.read", a.listTenants)) mux.HandleFunc("POST "+apiPrefix+"/tenants", a.withAuth("tenants.write", a.createTenant)) mux.HandleFunc("GET "+apiPrefix+"/projects", a.withAuth("projects.read", a.listProjects)) mux.HandleFunc("POST "+apiPrefix+"/projects", a.withAuth("projects.write", a.createProject)) mux.HandleFunc("GET "+apiPrefix+"/keys", a.withAuth("keys.read", a.listKeys)) mux.HandleFunc("POST "+apiPrefix+"/keys", a.withAuth("keys.write", a.createKey)) mux.HandleFunc("POST "+apiPrefix+"/keys/{id}/revoke", a.withAuth("keys.write", a.revokeKey)) mux.HandleFunc("GET "+apiPrefix+"/providers", a.withAuth("platform.read", a.listProviders)) mux.HandleFunc("POST "+apiPrefix+"/providers", a.withAuth("platform.write", a.createProvider)) mux.HandleFunc("POST "+apiPrefix+"/providers/{id}/toggle", a.withAuth("platform.write", a.toggleProvider)) mux.HandleFunc("GET "+apiPrefix+"/models", a.withAuth("platform.read", a.listModels)) mux.HandleFunc("POST "+apiPrefix+"/models", a.withAuth("platform.write", a.createModel)) mux.HandleFunc("POST "+apiPrefix+"/models/{id}/toggle", a.withAuth("platform.write", a.toggleModel)) mux.HandleFunc("POST "+apiPrefix+"/models/{id}/prices", a.withAuth("platform.write", a.createModelPriceVersion)) mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth("platform.write", a.reload)) if a.billing != nil { mux.HandleFunc("GET "+apiPrefix+"/billing/accounts", a.withAuth("billing.read", a.listBillingAccounts)) mux.HandleFunc("GET "+apiPrefix+"/billing/ledger", a.withAuth("billing.read", a.listBillingLedger)) mux.HandleFunc("GET "+apiPrefix+"/billing/orders", a.withAuth("billing.read", a.listTopUpOrders)) mux.HandleFunc("GET "+apiPrefix+"/billing/orders/{id}", a.withAuth("billing.read", a.getTopUpOrder)) mux.HandleFunc("POST "+apiPrefix+"/billing/adjustments", a.withAuth("billing.adjust", a.adjustBalance)) mux.HandleFunc("POST "+apiPrefix+"/billing/checkout-sessions", a.withAuth("billing.topup", a.createCheckoutSession)) mux.HandleFunc("POST "+apiPrefix+"/billing/portal-sessions", a.withAuth("billing.topup", a.createPortalSession)) mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/retry", a.withAuth("billing.topup", a.retryCheckoutSession)) mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/resolve-missing", a.withAuth("billing.adjust", a.resolveMissingTopUp)) mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/reverse-missing-credit", a.withAuth("billing.adjust", a.reverseMissingTopUpCredit)) mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/refund", a.withAuth("billing.adjust", a.createRefund)) mux.HandleFunc("GET "+apiPrefix+"/billing/refunds", a.withAuth("billing.read", a.listRefunds)) mux.HandleFunc("GET "+apiPrefix+"/billing/disputes", a.withAuth("billing.read", a.listDisputes)) mux.HandleFunc("GET "+apiPrefix+"/billing/invoices", a.withAuth("billing.read", a.listInvoices)) mux.HandleFunc("POST "+apiPrefix+"/billing/reconcile", a.withAuth("billing.adjust", a.reconcileBilling)) mux.HandleFunc("GET "+apiPrefix+"/billing/export.csv", a.withAuth("billing.read", a.exportBillingCSV)) } mux.HandleFunc("GET "+apiPrefix+"/usage", a.withAuth("usage.read", a.listUsage)) mux.HandleFunc("GET "+apiPrefix+"/usage/summary", a.withAuth("usage.read", a.usageSummary)) mux.HandleFunc("GET "+apiPrefix+"/limits", a.withAuth("limits.read", a.listLimits)) mux.HandleFunc("POST "+apiPrefix+"/limits/{project_id}", a.withAuth("limits.write", a.setLimit)) mux.HandleFunc("GET "+apiPrefix+"/users", a.withAuth("users.read", a.listUsers)) mux.HandleFunc("POST "+apiPrefix+"/users", a.withAuth("users.write", a.createUser)) mux.HandleFunc("POST "+apiPrefix+"/users/{id}/revoke", a.withAuth("users.write", a.revokeUser)) mux.HandleFunc("GET "+apiPrefix+"/audit", a.withAuth("audit.read", a.listAudit)) mux.HandleFunc("GET "+apiPrefix+"/me", a.withAuth("overview.read", a.me)) return a.securityHeaders(mux) } func (a *API) public(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a.prepareRequest(w, r) next(w, r) } } func (a *API) prepareRequest(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-AIGW-Request-ID") == "" { r.Header.Set("X-AIGW-Request-ID", adminRequestID(r)) } w.Header().Set("X-AIGW-Request-ID", r.Header.Get("X-AIGW-Request-ID")) } func (a *API) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), publickey-credentials-create=(self), publickey-credentials-get=(self)") if strings.Contains(r.URL.Path, "/api/") { w.Header().Set("Cache-Control", "no-store") } next.ServeHTTP(w, r) }) } func (a *API) withAuth(permission string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a.prepareRequest(w, r) provided := strings.TrimSpace(r.Header.Get("X-Admin-Token")) if provided == "" { provided = bearerToken(r.Header.Get("Authorization")) } actor := controlplane.ConsoleActor{} var authErr error if len(provided) > 0 && len(a.token) > 0 && subtle.ConstantTimeCompare([]byte(provided), a.token) == 1 { actor = controlplane.ConsoleActor{Role: controlplane.RolePlatformAdmin, DisplayName: "Bootstrap administrator", Bootstrap: true} } else if provided != "" && a.store != nil { actor, authErr = a.store.AuthenticateConsoleToken(r.Context(), provided) } else if cookie, err := r.Cookie("aigw_session"); err == nil && a.store != nil { var csrfHash []byte actor, csrfHash, authErr = a.store.AuthenticateConsoleSession(r.Context(), cookie.Value) if authErr == nil && isUnsafeMethod(r.Method) { providedCSRF := strings.TrimSpace(r.Header.Get("X-CSRF-Token")) actualCSRF := sha256.Sum256([]byte(providedCSRF)) if providedCSRF == "" || subtle.ConstantTimeCompare(actualCSRF[:], csrfHash) != 1 { apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "csrf_failed", Message: "Request verification failed; reload and try again"}, requestID(r)) return } } } if authErr != nil && !errors.Is(authErr, controlplane.ErrConsoleUnauthorized) { a.logger.Error("console_authentication_failed", "error", authErr) apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_unavailable", Message: "Control plane authentication is temporarily unavailable"}, requestID(r)) return } if actor.Role == "" { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "admin_unauthorized", Message: "Administrator authentication required"}, requestID(r)) return } if !actor.Can(permission) { aw := &auditWriter{ResponseWriter: w} apierror.Write(aw, apierror.Error{Status: http.StatusForbidden, Type: "admin_forbidden", Message: "You do not have permission for this operation"}, requestID(r)) a.writeAudit(r, actor, permission, aw.status) return } request := r.WithContext(context.WithValue(r.Context(), actorKey{}, actor)) aw := &auditWriter{ResponseWriter: w} next(aw, request) if aw.status == 0 { aw.status = http.StatusOK } a.writeAudit(request, actor, permission, aw.status) } } func (a *API) actor(r *http.Request) controlplane.ConsoleActor { actor, _ := r.Context().Value(actorKey{}).(controlplane.ConsoleActor) return actor } func (a *API) writeAudit(r *http.Request, actor controlplane.ConsoleActor, action string, status int) { if a.store == nil { return } ip := r.RemoteAddr if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { ip = host } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := a.store.WriteAudit(ctx, controlplane.AuditInput{Actor: actor, RequestID: requestID(r), Method: r.Method, Path: r.URL.Path, Action: action, StatusCode: status, RemoteIP: ip, UserAgent: r.UserAgent()}); err != nil && a.logger != nil { a.logger.Warn("admin_audit_write_failed", "error", err) } } func adminRequestID(r *http.Request) string { if id := r.Header.Get("X-AIGW-Request-ID"); id != "" { return id } var b [12]byte if _, err := rand.Read(b[:]); err == nil { return "adm_" + hex.EncodeToString(b[:]) } return "adm_unknown" } func (a *API) authConfig(w http.ResponseWriter, _ *http.Request) { writeJSON(w, map[string]any{"registration_enabled": a.registrationEnabled, "email_delivery_enabled": a.mailEnabled, "passkeys_enabled": a.webauthn != nil}) } func (a *API) authSession(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("aigw_session") if err != nil { writeJSON(w, map[string]any{"authenticated": false}) return } actor, _, err := a.store.AuthenticateConsoleSession(r.Context(), cookie.Value) if errors.Is(err, controlplane.ErrConsoleUnauthorized) { a.clearSessionCookie(w, r) writeJSON(w, map[string]any{"authenticated": false}) return } if err != nil { a.databaseError(w, r, err) return } writeJSON(w, map[string]any{"authenticated": true, "actor": actor, "permissions": actor.Permissions()}) } func (a *API) register(w http.ResponseWriter, r *http.Request) { if !a.registrationEnabled { apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "registration_disabled", Message: "New account registration is disabled"}, requestID(r)) return } var input controlplane.RegisterInput if !decodeBody(w, r, &input) { return } if !a.consumePublicRateLimit(w, r, "register_ip", remoteIP(r), 5, time.Hour) || !a.consumePublicRateLimit(w, r, "register_email", input.Email, 3, 24*time.Hour) { return } actor, generation, err := a.store.RegisterTenantPending(r.Context(), input, a.currency, a.publicURL, remoteIP(r)) if err != nil { a.writeAudit(r, controlplane.ConsoleActor{}, "auth.register", http.StatusBadRequest) a.mutationError(w, r, err) return } if a.manager != nil { if err := a.manager.AfterMutation(r.Context(), generation, "tenant", actor.TenantID); err != nil { a.logger.Warn("registration_snapshot_reload_failed", "tenant_id", actor.TenantID, "error", err) } } a.writeAudit(r, actor, "auth.register", http.StatusCreated) writeStatusJSON(w, http.StatusCreated, map[string]any{"status": "verification_required", "email": actor.Email, "delivery": map[bool]string{true: "queued", false: "pending_configuration"}[a.mailEnabled]}) } func (a *API) login(w http.ResponseWriter, r *http.Request) { var input controlplane.PasswordLoginInput if !decodeBody(w, r, &input) { return } actor, err := a.store.AuthenticateConsolePassword(r.Context(), input, remoteIP(r)) if err != nil { status := http.StatusUnauthorized typeName := "invalid_credentials" message := "Email or password is incorrect" if errors.Is(err, controlplane.ErrConsoleRateLimited) { status = http.StatusTooManyRequests typeName = "login_rate_limited" message = "Too many login attempts; try again in 15 minutes" w.Header().Set("Retry-After", "900") } else if !errors.Is(err, controlplane.ErrConsoleUnauthorized) { status = http.StatusServiceUnavailable typeName = "control_plane_unavailable" message = "Login is temporarily unavailable" a.logger.Error("console_password_login_failed", "error", err) } a.writeAudit(r, controlplane.ConsoleActor{}, "auth.login", status) apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r)) return } if len(actor.MFAMethods) > 0 { challenge, err := a.store.BeginMFAChallenge(r.Context(), actor, remoteIP(r), r.UserAgent()) if err != nil { a.databaseError(w, r, err) return } a.writeAudit(r, actor, "auth.login.password", http.StatusAccepted) writeStatusJSON(w, http.StatusAccepted, map[string]any{"mfa_required": true, "challenge_token": challenge.Token, "methods": challenge.Methods, "expires_at": challenge.ExpiresAt}) return } session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "password", false) if err != nil { a.databaseError(w, r, err) return } a.setSessionCookie(w, r, session) a.writeAudit(r, actor, "auth.login", http.StatusOK) writeJSON(w, sessionPayload(session)) } func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) { var input struct { Token string `json:"token"` } if !decodeBody(w, r, &input) { return } actor, err := a.store.VerifyEmailToken(r.Context(), input.Token) if err != nil { a.actionError(w, r, err) return } session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "email_link", false) if err != nil { a.databaseError(w, r, err) return } a.setSessionCookie(w, r, session) a.writeAudit(r, actor, "auth.email.verify", http.StatusOK) writeJSON(w, sessionPayload(session)) } func (a *API) resendVerification(w http.ResponseWriter, r *http.Request) { var input struct { Email string `json:"email"` } if !decodeBody(w, r, &input) { return } if !a.consumePublicRateLimit(w, r, "verify_resend_ip", remoteIP(r), 10, time.Hour) || !a.consumePublicRateLimit(w, r, "verify_resend_email", input.Email, 3, time.Hour) { return } if err := a.store.ResendVerification(r.Context(), input.Email, a.publicURL, remoteIP(r)); err != nil { a.databaseError(w, r, err) return } writeStatusJSON(w, http.StatusAccepted, map[string]any{"status": "queued"}) } func (a *API) requestPasswordReset(w http.ResponseWriter, r *http.Request) { var input struct { Email string `json:"email"` } if !decodeBody(w, r, &input) { return } if !a.consumePublicRateLimit(w, r, "password_reset_ip", remoteIP(r), 20, time.Hour) || !a.consumePublicRateLimit(w, r, "password_reset_email", input.Email, 3, time.Hour) { return } if err := a.store.RequestPasswordReset(r.Context(), input.Email, a.publicURL, remoteIP(r)); err != nil { a.databaseError(w, r, err) return } writeStatusJSON(w, http.StatusAccepted, map[string]any{"status": "queued"}) } func (a *API) completePasswordReset(w http.ResponseWriter, r *http.Request) { var input controlplane.PasswordResetInput if !decodeBody(w, r, &input) { return } if err := a.store.ResetPassword(r.Context(), input); err != nil { a.actionError(w, r, err) return } writeJSON(w, map[string]any{"status": "password_reset", "reauthentication_required": true}) } func (a *API) acceptInvite(w http.ResponseWriter, r *http.Request) { var input controlplane.InviteAcceptInput if !decodeBody(w, r, &input) { return } actor, err := a.store.AcceptInvite(r.Context(), input) if err != nil { a.actionError(w, r, err) return } session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "invite_link", false) if err != nil { a.databaseError(w, r, err) return } a.setSessionCookie(w, r, session) a.writeAudit(r, actor, "auth.invite.accept", http.StatusOK) writeJSON(w, sessionPayload(session)) } func (a *API) completeMFA(w http.ResponseWriter, r *http.Request) { var input controlplane.MFACodeInput if !decodeBody(w, r, &input) { return } actor, method, err := a.store.CompleteMFAChallenge(r.Context(), input, remoteIP(r)) if err != nil { a.actionError(w, r, err) return } session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "password+"+method, true) if err != nil { a.databaseError(w, r, err) return } a.setSessionCookie(w, r, session) a.writeAudit(r, actor, "auth.login."+method, http.StatusOK) writeJSON(w, sessionPayload(session)) } func (a *API) passkeyLoginOptions(w http.ResponseWriter, r *http.Request) { if !a.requireWebAuthn(w, r) { return } var input struct { Email string `json:"email"` } if !decodeBody(w, r, &input) { return } if !a.consumePublicRateLimit(w, r, "passkey_login_ip", remoteIP(r), 30, 15*time.Minute) || !a.consumePublicRateLimit(w, r, "passkey_login_email", input.Email, 10, 15*time.Minute) { return } actor, err := a.store.FindActiveConsoleUser(r.Context(), input.Email) if err != nil { a.passkeyAuthError(w, r, err) return } token, options, err := a.store.BeginWebAuthnLogin(r.Context(), actor.ID, "login", "", a.webauthn) if err != nil { a.passkeyAuthError(w, r, err) return } writeJSON(w, map[string]any{"challenge_token": token, "options": options}) } func (a *API) passkeyLogin(w http.ResponseWriter, r *http.Request) { a.finishPasskeyLogin(w, r, "login", "passkey") } func (a *API) beginPasskeyMFA(w http.ResponseWriter, r *http.Request) { if !a.requireWebAuthn(w, r) { return } var input struct { ChallengeToken string `json:"challenge_token"` } if !decodeBody(w, r, &input) { return } authChallengeID, userID, err := a.store.ResolveMFAChallenge(r.Context(), input.ChallengeToken, remoteIP(r)) if err != nil { a.actionError(w, r, err) return } token, options, err := a.store.BeginWebAuthnLogin(r.Context(), userID, "mfa_login", authChallengeID, a.webauthn) if err != nil { a.passkeyAuthError(w, r, err) return } writeJSON(w, map[string]any{"challenge_token": token, "options": options}) } func (a *API) finishPasskeyMFA(w http.ResponseWriter, r *http.Request) { a.finishPasskeyLogin(w, r, "mfa_login", "password+passkey") } type webAuthnFinishInput struct { ChallengeToken string `json:"challenge_token"` Name string `json:"name"` Credential json.RawMessage `json:"credential"` } func (a *API) finishPasskeyLogin(w http.ResponseWriter, r *http.Request, purpose, authMethod string) { if !a.requireWebAuthn(w, r) { return } var input webAuthnFinishInput if !decodeBody(w, r, &input) { return } userID, authChallengeID, sessionData, err := a.store.WebAuthnSession(r.Context(), input.ChallengeToken, purpose) if err != nil { a.actionError(w, r, err) return } user, err := a.store.WebAuthnUser(r.Context(), userID) if err != nil { a.passkeyAuthError(w, r, err) return } credential, err := a.webauthn.FinishLogin(user, sessionData, credentialRequest(r, input.Credential)) if err != nil { a.passkeyAuthError(w, r, err) return } actor, err := a.store.FinishWebAuthnLogin(r.Context(), input.ChallengeToken, userID, authChallengeID, credential, purpose) if err != nil { a.passkeyAuthError(w, r, err) return } session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), authMethod, true) if err != nil { a.databaseError(w, r, err) return } a.setSessionCookie(w, r, session) a.writeAudit(r, actor, "auth.login.passkey", http.StatusOK) writeJSON(w, sessionPayload(session)) } func (a *API) logout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie("aigw_session"); err == nil { if err := a.store.RevokeConsoleSession(r.Context(), cookie.Value); err != nil { a.databaseError(w, r, err) return } } a.clearSessionCookie(w, r) writeJSON(w, map[string]any{"status": "signed_out"}) } func (a *API) changePassword(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) if actor.ID == "" { apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "bootstrap_account", Message: "Bootstrap access does not have a password"}, requestID(r)) return } var input controlplane.PasswordChangeInput if !decodeBody(w, r, &input) { return } if err := a.store.ChangeConsolePassword(r.Context(), actor.ID, input); err != nil { if errors.Is(err, controlplane.ErrConsoleUnauthorized) { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) return } a.mutationError(w, r, err) return } a.clearSessionCookie(w, r) writeJSON(w, map[string]any{"status": "password_changed", "reauthentication_required": true}) } func (a *API) mfaStatus(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) if actor.ID == "" { apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "bootstrap_account", Message: "Bootstrap access has no MFA profile"}, requestID(r)) return } result, err := a.store.MFAStatus(r.Context(), actor.ID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) beginTOTP(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) var input struct { CurrentPassword string `json:"current_password"` } if !decodeBody(w, r, &input) { return } if actor.ID == "" || a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) return } result, err := a.store.BeginTOTP(r.Context(), actor) if err != nil { a.mutationError(w, r, err) return } writeJSON(w, result) } func (a *API) confirmTOTP(w http.ResponseWriter, r *http.Request) { var input struct { Code string `json:"code"` } if !decodeBody(w, r, &input) { return } codes, err := a.store.ConfirmTOTP(r.Context(), a.actor(r).ID, input.Code) if err != nil { a.actionError(w, r, err) return } writeJSON(w, map[string]any{"status": "enabled", "recovery_codes": codes}) } func (a *API) disableTOTP(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) var input struct { CurrentPassword string `json:"current_password"` Code string `json:"code"` } if !decodeBody(w, r, &input) { return } if a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil || a.store.VerifyTOTPForUser(r.Context(), actor.ID, input.Code) != nil { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Password or authenticator code is incorrect"}, requestID(r)) return } if err := a.store.DisableTOTP(r.Context(), actor.ID); err != nil { a.databaseError(w, r, err) return } writeJSON(w, map[string]any{"status": "disabled"}) } func (a *API) beginPasskeyRegistration(w http.ResponseWriter, r *http.Request) { if !a.requireWebAuthn(w, r) { return } actor := a.actor(r) var input struct { CurrentPassword string `json:"current_password"` } if !decodeBody(w, r, &input) { return } if actor.ID == "" || a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) return } token, options, err := a.store.BeginWebAuthnRegistration(r.Context(), actor.ID, a.webauthn) if err != nil { a.passkeyAuthError(w, r, err) return } writeJSON(w, map[string]any{"challenge_token": token, "options": options}) } func (a *API) finishPasskeyRegistration(w http.ResponseWriter, r *http.Request) { if !a.requireWebAuthn(w, r) { return } var input webAuthnFinishInput if !decodeBody(w, r, &input) { return } actor := a.actor(r) userID, _, sessionData, err := a.store.WebAuthnSession(r.Context(), input.ChallengeToken, "register") if err != nil || userID != actor.ID { a.actionError(w, r, controlplane.ErrActionTokenInvalid) return } user, err := a.store.WebAuthnUser(r.Context(), actor.ID) if err != nil { a.passkeyAuthError(w, r, err) return } credential, err := a.webauthn.FinishRegistration(user, sessionData, credentialRequest(r, input.Credential)) if err != nil { a.passkeyAuthError(w, r, err) return } if _, err := a.store.FinishWebAuthnRegistration(r.Context(), input.ChallengeToken, actor.ID, input.Name, credential); err != nil { a.passkeyAuthError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, map[string]any{"status": "registered"}) } func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) var input struct { CurrentPassword string `json:"current_password"` } if !decodeBody(w, r, &input) { return } if a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) return } if err := a.store.DeletePasskey(r.Context(), actor.ID, r.PathValue("id")); err != nil { a.mutationError(w, r, err) return } writeJSON(w, map[string]any{"status": "deleted"}) } func (a *API) listSessions(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) if actor.ID == "" { writeJSON(w, []controlplane.DeviceSession{}) return } result, err := a.store.ListDeviceSessions(r.Context(), actor.ID, sessionCookie(r)) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) revokeSession(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) current := sessionCookie(r) sessions, err := a.store.ListDeviceSessions(r.Context(), actor.ID, current) if err != nil { a.databaseError(w, r, err) return } currentRevoked := false for _, session := range sessions { if session.ID == r.PathValue("id") { currentRevoked = session.Current break } } if _, err := a.store.RevokeDeviceSession(r.Context(), actor.ID, r.PathValue("id")); err != nil { a.mutationError(w, r, err) return } if currentRevoked { a.clearSessionCookie(w, r) } writeJSON(w, map[string]any{"status": "revoked", "current": currentRevoked}) } func (a *API) revokeOtherSessions(w http.ResponseWriter, r *http.Request) { if err := a.store.RevokeOtherDeviceSessions(r.Context(), a.actor(r).ID, sessionCookie(r)); err != nil { a.databaseError(w, r, err) return } writeJSON(w, map[string]any{"status": "revoked"}) } func sessionPayload(session controlplane.ConsoleSession) map[string]any { return map[string]any{ "actor": session.Actor, "permissions": session.Actor.Permissions(), "csrf_token": session.CSRFToken, "expires_at": session.ExpiresAt, "session_id": session.ID, "auth_method": session.AuthMethod, } } func (a *API) setSessionCookie(w http.ResponseWriter, r *http.Request, session controlplane.ConsoleSession) { maxAge := int(time.Until(session.ExpiresAt).Seconds()) http.SetCookie(w, &http.Cookie{Name: "aigw_session", Value: session.Token, Path: a.prefix + "/", MaxAge: maxAge, Expires: session.ExpiresAt, HttpOnly: true, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) http.SetCookie(w, &http.Cookie{Name: "aigw_csrf", Value: session.CSRFToken, Path: a.prefix + "/", MaxAge: maxAge, Expires: session.ExpiresAt, HttpOnly: false, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) } func (a *API) clearSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{Name: "aigw_session", Value: "", Path: a.prefix + "/", MaxAge: -1, Expires: time.Unix(1, 0), HttpOnly: true, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) http.SetCookie(w, &http.Cookie{Name: "aigw_csrf", Value: "", Path: a.prefix + "/", MaxAge: -1, Expires: time.Unix(1, 0), HttpOnly: false, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) } func requestIsHTTPS(r *http.Request) bool { return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") } func sessionCookie(r *http.Request) string { if cookie, err := r.Cookie("aigw_session"); err == nil { return cookie.Value } return "" } func credentialRequest(r *http.Request, credential json.RawMessage) *http.Request { request := r.Clone(r.Context()) request.Body = io.NopCloser(bytes.NewReader(credential)) request.ContentLength = int64(len(credential)) request.Header = r.Header.Clone() request.Header.Set("Content-Type", "application/json") return request } func (a *API) requireWebAuthn(w http.ResponseWriter, r *http.Request) bool { if a.webauthn != nil { return true } apierror.Write(w, apierror.Error{Status: http.StatusNotImplemented, Type: "passkeys_disabled", Message: "Passkeys are not configured for this console"}, requestID(r)) return false } func (a *API) consumePublicRateLimit(w http.ResponseWriter, r *http.Request, scope, key string, limit int, window time.Duration) bool { if err := a.store.ConsumeRateLimit(r.Context(), scope, key, limit, window); err != nil { if errors.Is(err, controlplane.ErrConsoleRateLimited) { w.Header().Set("Retry-After", "900") apierror.Write(w, apierror.Error{Status: http.StatusTooManyRequests, Type: "rate_limited", Message: "Too many requests; try again later"}, requestID(r)) return false } a.databaseError(w, r, err) return false } return true } func (a *API) actionError(w http.ResponseWriter, r *http.Request, err error) { if errors.Is(err, controlplane.ErrActionTokenInvalid) { apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "invalid_or_expired_token", Message: "This link or authentication challenge is invalid or expired"}, requestID(r)) return } if errors.Is(err, controlplane.ErrConsoleUnauthorized) { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_authenticator", Message: "The authentication code is incorrect"}, requestID(r)) return } a.mutationError(w, r, err) } func (a *API) passkeyAuthError(w http.ResponseWriter, r *http.Request, err error) { if !errors.Is(err, controlplane.ErrConsoleUnauthorized) && !errors.Is(err, controlplane.ErrNoPasskeys) && !errors.Is(err, controlplane.ErrActionTokenInvalid) { a.logger.Warn("passkey_authentication_failed", "error", err) } apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "passkey_failed", Message: "Passkey authentication could not be completed"}, requestID(r)) } func isUnsafeMethod(method string) bool { return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions } func remoteIP(r *http.Request) string { if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return host } return r.RemoteAddr } func (a *API) overview(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) tenantID := actor.TenantID result, err := a.store.OverviewFor(r.Context(), tenantID) if err != nil { a.databaseError(w, r, err) return } result.RuntimeGeneration = a.manager.Generation() result.RedisConfigured = a.manager.RedisConfigured() result.RedisConnected = a.manager.RedisConnected() result.BillingEnabled = a.billing != nil if a.billing != nil { result.StripeEnabled = a.billing.StripeEnabled() result.BillingCurrency = a.billing.Currency() } writeJSON(w, result) } func (a *API) listBillingAccounts(w http.ResponseWriter, r *http.Request) { result, err := a.billing.ListAccounts(r.Context(), a.actor(r).TenantID) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) listBillingLedger(w http.ResponseWriter, r *http.Request) { tenantID := a.actor(r).TenantID if tenantID == "" { tenantID = r.URL.Query().Get("tenant_id") } result, err := a.billing.ListLedger(r.Context(), tenantID, 200) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) listTopUpOrders(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) tenantID := actor.TenantID if tenantID == "" { tenantID = r.URL.Query().Get("tenant_id") } result, err := a.billing.ListTopUpOrders(r.Context(), tenantID, 100) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) getTopUpOrder(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) result, err := a.billing.GetTopUpOrder(r.Context(), actor.TenantID, r.PathValue("id")) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) adjustBalance(w http.ResponseWriter, r *http.Request) { var input billing.AdjustmentInput if !decodeBody(w, r, &input) { return } result, err := a.billing.AdjustBalance(r.Context(), input) if err != nil { a.billingError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) createCheckoutSession(w http.ResponseWriter, r *http.Request) { var input billing.CheckoutInput if !decodeBody(w, r, &input) { return } if tenantID := a.actor(r).TenantID; tenantID != "" { input.TenantID = tenantID } input.CustomerEmail = a.actor(r).Email result, err := a.billing.CreateCheckout(r.Context(), input) if err != nil { a.billingError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) createPortalSession(w http.ResponseWriter, r *http.Request) { tenantID := a.actor(r).TenantID if tenantID == "" { apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "A tenant is required"}, requestID(r)) return } result, err := a.billing.CreatePortalSession(r.Context(), tenantID) if err != nil { a.billingError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) retryCheckoutSession(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) if actor.TenantID == "" { a.scopeError(w, r) return } result, err := a.billing.RetryCheckout(r.Context(), actor.TenantID, r.PathValue("id"), actor.Email) if err != nil { a.billingError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) resolveMissingTopUp(w http.ResponseWriter, r *http.Request) { var input billing.ResolveMissingTopUpInput if !decodeBody(w, r, &input) { return } tenantID := r.URL.Query().Get("tenant_id") if actorTenant := a.actor(r).TenantID; actorTenant != "" { tenantID = actorTenant } if tenantID == "" { a.scopeError(w, r) return } actor := a.actor(r) actorType := "console_user" if actor.Bootstrap { actorType = "bootstrap" } result, err := a.billing.ResolveMissingTopUp(r.Context(), tenantID, r.PathValue("id"), input, billing.ResolutionActor{ID: actor.ID, Type: actorType}) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) reverseMissingTopUpCredit(w http.ResponseWriter, r *http.Request) { var input billing.ResolveMissingTopUpInput if !decodeBody(w, r, &input) { return } tenantID := r.URL.Query().Get("tenant_id") actor := a.actor(r) if actor.TenantID != "" { tenantID = actor.TenantID } if tenantID == "" { a.scopeError(w, r) return } actorType := "console_user" if actor.Bootstrap { actorType = "bootstrap" } result, err := a.billing.ReverseMissingTopUpCredit(r.Context(), tenantID, r.PathValue("id"), input, billing.ResolutionActor{ID: actor.ID, Type: actorType}) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) createRefund(w http.ResponseWriter, r *http.Request) { var input billing.RefundInput if !decodeBody(w, r, &input) { return } tenantID := r.URL.Query().Get("tenant_id") if actorTenant := a.actor(r).TenantID; actorTenant != "" { tenantID = actorTenant } if tenantID == "" { apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "tenant_id is required"}, requestID(r)) return } result, err := a.billing.CreateRefund(r.Context(), tenantID, r.PathValue("id"), input) if err != nil { a.billingError(w, r, err) return } writeStatusJSON(w, http.StatusAccepted, result) } func (a *API) listRefunds(w http.ResponseWriter, r *http.Request) { tenantID := a.actor(r).TenantID if tenantID == "" { tenantID = r.URL.Query().Get("tenant_id") } result, err := a.billing.ListRefunds(r.Context(), tenantID, 200) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) listDisputes(w http.ResponseWriter, r *http.Request) { tenantID := a.actor(r).TenantID if tenantID == "" { tenantID = r.URL.Query().Get("tenant_id") } result, err := a.billing.ListDisputes(r.Context(), tenantID, 200) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) listInvoices(w http.ResponseWriter, r *http.Request) { tenantID := a.actor(r).TenantID if tenantID == "" { tenantID = r.URL.Query().Get("tenant_id") } result, err := a.billing.ListInvoices(r.Context(), tenantID, 200) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) reconcileBilling(w http.ResponseWriter, r *http.Request) { result, err := a.billing.Reconcile(r.Context(), 200) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) exportBillingCSV(w http.ResponseWriter, r *http.Request) { tenantID := a.actor(r).TenantID if tenantID == "" { tenantID = r.URL.Query().Get("tenant_id") } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="aigw-financial-ledger.csv"`) if err := a.billing.WriteFinancialCSV(r.Context(), tenantID, w); err != nil { a.logger.Error("billing_export_failed", "error", err) } } func (a *API) listTenants(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListTenantsFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createTenant(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateTenantInput if !decodeBody(w, r, &input) { return } result, generation, err := a.store.CreateTenant(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "tenant", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) listProjects(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListProjectsFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createProject(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateProjectInput if !decodeBody(w, r, &input) { return } if tenantID := a.actor(r).TenantID; tenantID != "" { input.TenantID = tenantID } result, generation, err := a.store.CreateProject(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "project", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) listKeys(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListAPIKeysFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createKey(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateAPIKeyInput if !decodeBody(w, r, &input) { return } if tenantID := a.actor(r).TenantID; tenantID != "" { input.TenantID = tenantID } result, generation, err := a.store.CreateAPIKey(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "api_key", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) revokeKey(w http.ResponseWriter, r *http.Request) { if err := a.requireResourceTenant(r, "api_key", r.PathValue("id")); err != nil { a.scopeError(w, r) return } generation, err := a.store.RevokeAPIKey(r.Context(), r.PathValue("id")) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "api_key", r.PathValue("id")) { return } writeJSON(w, map[string]any{"status": "revoked"}) } func (a *API) listProviders(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListProviders(r.Context()) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createProvider(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateProviderInput if !decodeBody(w, r, &input) { return } result, generation, err := a.store.CreateProvider(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "provider", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) toggleProvider(w http.ResponseWriter, r *http.Request) { var input struct { Enabled bool `json:"enabled"` } if !decodeBody(w, r, &input) { return } id := r.PathValue("id") generation, err := a.store.SetProviderEnabled(r.Context(), id, input.Enabled) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "provider", id) { return } writeJSON(w, map[string]any{"id": id, "enabled": input.Enabled}) } func (a *API) listModels(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListModels(r.Context()) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createModel(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateModelInput if !decodeBody(w, r, &input) { return } result, generation, err := a.store.CreateModel(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "model", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) toggleModel(w http.ResponseWriter, r *http.Request) { var input struct { Enabled bool `json:"enabled"` } if !decodeBody(w, r, &input) { return } id := r.PathValue("id") generation, err := a.store.SetModelEnabled(r.Context(), id, input.Enabled) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "model", id) { return } writeJSON(w, map[string]any{"id": id, "enabled": input.Enabled}) } func (a *API) createModelPriceVersion(w http.ResponseWriter, r *http.Request) { var input controlplane.CreatePriceVersionInput if !decodeBody(w, r, &input) { return } id := r.PathValue("id") generation, err := a.store.CreateModelPriceVersion(r.Context(), id, input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "model_price", id) { return } writeStatusJSON(w, http.StatusCreated, map[string]any{"model_id": id, "generation": generation}) } func (a *API) reload(w http.ResponseWriter, r *http.Request) { generation, err := a.manager.Reload(r.Context()) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, map[string]any{"generation": generation, "status": "reloaded"}) } func (a *API) listUsage(w http.ResponseWriter, r *http.Request) { query := controlplane.UsageQuery{TenantID: a.actor(r).TenantID, ProjectID: r.URL.Query().Get("project_id"), Model: r.URL.Query().Get("model"), Limit: 200} result, err := a.store.ListUsage(r.Context(), query) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) usageSummary(w http.ResponseWriter, r *http.Request) { result, err := a.store.UsageSummary(r.Context(), a.actor(r).TenantID, r.URL.Query().Get("project_id")) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) listLimits(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListProjectLimits(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) setLimit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("project_id") if err := a.requireResourceTenant(r, "project", id); err != nil { a.scopeError(w, r) return } var input controlplane.SetProjectLimitInput if !decodeBody(w, r, &input) { return } result, generation, err := a.store.SetProjectLimit(r.Context(), id, input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "project_limit", id) { return } writeJSON(w, result) } func (a *API) listUsers(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListConsoleUsers(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createUser(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateConsoleUserInput if !decodeBody(w, r, &input) { return } if tenantID := a.actor(r).TenantID; tenantID != "" { input.TenantID = tenantID if strings.HasPrefix(input.Role, "platform_") { a.scopeError(w, r) return } } result, err := a.store.InviteConsoleUser(r.Context(), input, a.publicURL, remoteIP(r)) if err != nil { a.mutationError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) revokeUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := a.requireResourceTenant(r, "console_user", id); err != nil { a.scopeError(w, r) return } if err := a.store.RevokeConsoleUser(r.Context(), id, a.actor(r).TenantID); err != nil { a.mutationError(w, r, err) return } writeJSON(w, map[string]any{"status": "revoked", "id": id}) } func (a *API) listAudit(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListAudit(r.Context(), a.actor(r).TenantID, 200) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) me(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) writeJSON(w, map[string]any{"actor": actor, "permissions": actor.Permissions()}) } func (a *API) requireResourceTenant(r *http.Request, resource, id string) error { actor := a.actor(r) if actor.IsPlatform() { return nil } tenantID, err := a.store.ResourceTenantID(r.Context(), resource, id) if err != nil || tenantID == "" || tenantID != actor.TenantID { return errors.New("resource is outside tenant scope") } return nil } func (a *API) scopeError(w http.ResponseWriter, r *http.Request) { apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "tenant_scope", Message: "Resource is outside your tenant"}, requestID(r)) } func (a *API) changed(w http.ResponseWriter, r *http.Request, generation int64, resource, id string) bool { if err := a.manager.AfterMutation(r.Context(), generation, resource, id); err != nil { a.logger.Error("admin_control_plane_sync_failed", "resource", resource, "id", id, "error", err) apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_sync_failed", Message: "Change was persisted but runtime reload failed; retry reload"}, requestID(r)) return false } return true } func (a *API) databaseError(w http.ResponseWriter, r *http.Request, err error) { a.logger.Error("admin_database_error", "error", err) apierror.Write(w, apierror.Error{Status: http.StatusInternalServerError, Type: "control_plane_error", Message: "Control plane unavailable"}, requestID(r)) } func (a *API) mutationError(w http.ResponseWriter, r *http.Request, err error) { status := http.StatusBadRequest typeName := "invalid_params" message := err.Error() if errors.Is(err, controlplane.ErrNotFound) { status = http.StatusNotFound typeName = "not_found" message = "Resource not found" } var pgError *pgconn.PgError if errors.As(err, &pgError) { switch pgError.Code { case "23505": status = http.StatusConflict typeName = "already_exists" message = "Resource already exists" case "23503": status = http.StatusBadRequest typeName = "invalid_reference" message = "Referenced resource does not exist" } } apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r)) } func (a *API) billingError(w http.ResponseWriter, r *http.Request, err error) { status := http.StatusInternalServerError typeName := "billing_error" message := "Billing operation failed" switch { case errors.Is(err, billing.ErrInvalidAmount): status = http.StatusBadRequest typeName = "invalid_amount" message = "Amount is outside the configured bounds" case errors.Is(err, billing.ErrInsufficientBalance): status = http.StatusPaymentRequired typeName = "insufficient_balance" message = "Available balance is insufficient" case errors.Is(err, billing.ErrStripeDisabled): status = http.StatusServiceUnavailable typeName = "stripe_disabled" message = "Stripe top-ups are disabled" case errors.Is(err, billing.ErrTopUpOrderNotFound): status = http.StatusNotFound typeName = "topup_order_not_found" message = "Top-up order was not found" case errors.Is(err, billing.ErrCannotResolveTopUp): status = http.StatusConflict typeName = "topup_order_not_resolvable" message = err.Error() default: a.logger.Error("admin_billing_error", "error", err) } apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r)) } func decodeBody(w http.ResponseWriter, r *http.Request, destination any) bool { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) defer r.Body.Close() decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(destination); err != nil { apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "invalid_params", Message: "Invalid JSON request body"}, requestID(r)) return false } return true } func writeJSON(w http.ResponseWriter, value any) { writeStatusJSON(w, http.StatusOK, value) } func writeStatusJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func bearerToken(header string) string { parts := strings.Fields(header) if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { return parts[1] } return "" } func requestID(r *http.Request) string { if value := r.Header.Get("X-AIGW-Request-ID"); value != "" { return value } return adminRequestID(r) }