package adminapi import ( "crypto/subtle" "encoding/json" "errors" "log/slog" "net/http" "strings" "aigw/internal/adminui" "aigw/internal/apierror" "aigw/internal/controlplane" "github.com/jackc/pgx/v5/pgconn" ) type API struct { store *controlplane.Store manager *controlplane.Manager token []byte logger *slog.Logger prefix string } type Options struct { Store *controlplane.Store Manager *controlplane.Manager Token string Logger *slog.Logger Prefix string } func New(options Options) *API { prefix := strings.TrimRight(options.Prefix, "/") if prefix == "" { prefix = "/admin" } return &API{store: options.Store, manager: options.Manager, token: []byte(options.Token), logger: options.Logger, prefix: prefix} } 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) { http.Redirect(w, r, a.prefix+"/", http.StatusTemporaryRedirect) }) mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler())) mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth(a.overview)) mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth(a.listTenants)) mux.HandleFunc("POST "+apiPrefix+"/tenants", a.withAuth(a.createTenant)) mux.HandleFunc("GET "+apiPrefix+"/projects", a.withAuth(a.listProjects)) mux.HandleFunc("POST "+apiPrefix+"/projects", a.withAuth(a.createProject)) mux.HandleFunc("GET "+apiPrefix+"/keys", a.withAuth(a.listKeys)) mux.HandleFunc("POST "+apiPrefix+"/keys", a.withAuth(a.createKey)) mux.HandleFunc("POST "+apiPrefix+"/keys/{id}/revoke", a.withAuth(a.revokeKey)) mux.HandleFunc("GET "+apiPrefix+"/providers", a.withAuth(a.listProviders)) mux.HandleFunc("POST "+apiPrefix+"/providers", a.withAuth(a.createProvider)) mux.HandleFunc("POST "+apiPrefix+"/providers/{id}/toggle", a.withAuth(a.toggleProvider)) mux.HandleFunc("GET "+apiPrefix+"/models", a.withAuth(a.listModels)) mux.HandleFunc("POST "+apiPrefix+"/models", a.withAuth(a.createModel)) mux.HandleFunc("POST "+apiPrefix+"/models/{id}/toggle", a.withAuth(a.toggleModel)) mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth(a.reload)) return mux } func (a *API) withAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { provided := strings.TrimSpace(r.Header.Get("X-Admin-Token")) if provided == "" { provided = bearerToken(r.Header.Get("Authorization")) } if len(provided) == 0 || subtle.ConstantTimeCompare([]byte(provided), a.token) != 1 { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "admin_unauthorized", Message: "Administrator authentication required"}, requestID(r)) return } next(w, r) } } func (a *API) overview(w http.ResponseWriter, r *http.Request) { result, err := a.store.Overview(r.Context()) if err != nil { a.databaseError(w, r, err) return } result.RuntimeGeneration = a.manager.Generation() result.RedisConfigured = a.manager.RedisConfigured() result.RedisConnected = a.manager.RedisConnected() writeJSON(w, result) } func (a *API) listTenants(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListTenants(r.Context()) 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.ListProjects(r.Context()) 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 } 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.ListAPIKeys(r.Context()) 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 } 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) { 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) 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) 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 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 "admin" }