2835 lines
100 KiB
Go
2835 lines
100 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/dto"
|
|
"browser.local/platform/service"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
type coreHandlers struct {
|
|
core service.Core
|
|
enforceAuthorization bool
|
|
}
|
|
|
|
func newCoreHandlers(core service.Core, enforceAuthorization bool) *coreHandlers {
|
|
return &coreHandlers{core: core, enforceAuthorization: enforceAuthorization}
|
|
}
|
|
|
|
func (h *coreHandlers) register(mux *http.ServeMux) {
|
|
mux.HandleFunc("/api/v1/auth/register", h.authRegister)
|
|
mux.HandleFunc("/api/v1/auth/login", h.authLogin)
|
|
mux.HandleFunc("/api/v1/auth/logout", h.authLogout)
|
|
mux.HandleFunc("/api/v1/auth/rotate", h.authRotate)
|
|
mux.HandleFunc("/api/v1/users/current", h.currentUser)
|
|
mux.HandleFunc("/api/v1/users/current/profile", h.currentUserProfile)
|
|
mux.HandleFunc("/api/v1/users/current/theme", h.currentUserTheme)
|
|
mux.HandleFunc("/api/v1/users", h.users)
|
|
mux.HandleFunc("/api/v1/users/{id}", h.userDetail)
|
|
mux.HandleFunc("/api/v1/ai-providers", h.aiProviders)
|
|
mux.HandleFunc("/api/v1/ai-providers/{id}", h.aiProviderDetail)
|
|
mux.HandleFunc("/api/v1/ai-providers/{id}/status", h.aiProviderStatus)
|
|
mux.HandleFunc("/api/v1/ai-providers/{id}/test", h.aiProviderTest)
|
|
mux.HandleFunc("/api/v1/ai-providers/{id}/models", h.aiProviderModels)
|
|
mux.HandleFunc("/api/v1/ai/invocations", h.aiInvocation)
|
|
mux.HandleFunc("/api/v1/ai/config-suggestions", h.aiConfigSuggestion)
|
|
mux.HandleFunc("/api/v1/game-plugins", h.gamePlugins)
|
|
mux.HandleFunc("/api/v1/game-plugins/register-manifest", h.gamePluginManifestRegistration)
|
|
mux.HandleFunc("/api/v1/plugin-marketplace/plugins", h.marketplacePlugins)
|
|
mux.HandleFunc("/api/v1/plugin-marketplace/plugins/{id}/state", h.marketplacePluginState)
|
|
mux.HandleFunc("/api/v1/plugin-marketplace/plugins/{id}", h.marketplacePluginDetail)
|
|
mux.HandleFunc("/api/v1/plugin-bridge/authorize", h.pluginBridgeAuthorize)
|
|
mux.HandleFunc("/api/v1/plugin-bridge/execute", h.pluginBridgeExecute)
|
|
mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail)
|
|
mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics)
|
|
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
|
mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles)
|
|
mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction)
|
|
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
|
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
|
mux.HandleFunc("/api/v1/backups", h.backups)
|
|
mux.HandleFunc("/api/v1/backups/{id}", h.backupDetail)
|
|
mux.HandleFunc("/api/v1/server-instances", h.serverInstances)
|
|
mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/restart", h.serverInstanceRestart)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/update", h.serverInstanceUpdate)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/deployment/reveal", h.serverDeploymentReveal)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/run/update", h.serverRunUpdate)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge", h.serverGameClientBridgeStatus)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands", h.serverGameClientBridgeCommands)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}/cancel", h.serverGameClientBridgeCommandCancel)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}", h.serverGameClientBridgeCommandDetail)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/scum/users", h.serverSCUMUsers)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/scum/user-trajectories", h.serverSCUMUserTrajectories)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicle-trajectories", h.serverSCUMVehicleTrajectories)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicle-locks", h.serverSCUMVehicleLocks)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/scum/surface", h.serverSCUMSurface)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}", h.serverPluginDataCollection)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}/transaction", h.serverPluginDataTransaction)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/workspace", h.serverFilesWorkspace)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/list", h.serverFilesList)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/browse", h.serverFilesBrowse)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/refresh", h.serverFilesRefresh)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverFilesReadSnapshot)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/read", h.serverFilesRead)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/write", h.serverFilesWrite)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/upload", h.serverFilesUpload)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/files/download", h.serverFilesDownload)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail)
|
|
mux.HandleFunc("/api/v1/server-instances/{id}", h.serverInstanceDetail)
|
|
mux.HandleFunc("/api/v1/run/control/hello", h.runControlHello)
|
|
mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat))
|
|
mux.HandleFunc("/api/v1/run/control/events", h.requireRunSignature(h.runControlEvents))
|
|
mux.HandleFunc("/api/v1/run/lifecycle/report", h.requireRunSignature(h.runLifecycleReport))
|
|
mux.HandleFunc("/api/v1/run/scum/facts", h.requireRunSignature(h.runSCUMFacts))
|
|
mux.HandleFunc("/api/v1/run/jobs/claim", h.requireRunSignature(h.runJobClaim))
|
|
mux.HandleFunc("/api/v1/run/jobs/ack", h.requireRunSignature(h.runJobAck))
|
|
mux.HandleFunc("/api/v1/run/jobs/progress", h.requireRunSignature(h.runJobProgress))
|
|
mux.HandleFunc("/api/v1/run/jobs/result", h.requireRunSignature(h.runJobResult))
|
|
mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput))
|
|
mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput))
|
|
mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput))
|
|
mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput))
|
|
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
|
|
mux.HandleFunc("/api/v1/run/files/input-chunk", h.requireRunSignature(h.runFileInputChunk))
|
|
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
|
|
mux.HandleFunc("/api/v1/run/jobs/cancel", h.requireRunSignature(h.runJobCancelPoll))
|
|
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.requireRunSignature(h.runJobReconcile))
|
|
mux.HandleFunc("/api/v1/run/logs/batches", h.requireRunSignature(h.runLogBatchIngest))
|
|
mux.HandleFunc("/api/v1/run/logs/progress", h.requireRunSignature(h.runLogStreamProgress))
|
|
mux.HandleFunc("/api/v1/run/artifacts/open", h.requireRunSignature(h.runArtifactOpen))
|
|
mux.HandleFunc("/api/v1/run/artifacts/chunks", h.requireRunSignature(h.runArtifactChunkUpload))
|
|
mux.HandleFunc("/api/v1/run/artifacts/status", h.requireRunSignature(h.runArtifactStatus))
|
|
mux.HandleFunc("/api/v1/run/artifacts/complete", h.requireRunSignature(h.runArtifactComplete))
|
|
mux.HandleFunc("/api/v1/run/endpoints", h.runEndpoints)
|
|
mux.HandleFunc("/api/v1/run/endpoints/{id}", h.runEndpointDetail)
|
|
mux.HandleFunc("/api/v1/jobs", h.jobs)
|
|
mux.HandleFunc("/api/v1/jobs/{id}/cancel", h.jobCancel)
|
|
mux.HandleFunc("/api/v1/jobs/{id}", h.jobDetail)
|
|
mux.HandleFunc("/api/v1/file-operations/dispatch", h.fileOperationDispatch)
|
|
mux.HandleFunc("/api/v1/artifacts", h.artifacts)
|
|
mux.HandleFunc("/api/v1/artifacts/{id}/download", h.artifactDownload)
|
|
mux.HandleFunc("/api/v1/artifacts/{id}/content", h.artifactContent)
|
|
mux.HandleFunc("/api/v1/artifacts/{id}", h.artifactDetail)
|
|
mux.HandleFunc("/api/v1/log-streams", h.logStreams)
|
|
mux.HandleFunc("/api/v1/log-streams/query", h.logStreamQuery)
|
|
mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail)
|
|
}
|
|
|
|
// pluginLifecycles godoc
|
|
// @Summary List server-bound plugin lifecycle state
|
|
// @Description Lists durable plugin installation, desired/current state, compatibility, dependency, and job metadata.
|
|
// @Tags plugin-operations
|
|
// @Produce json
|
|
// @Success 200 {object} dto.PluginLifecycleListResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/plugin-lifecycles [get]
|
|
func (h *coreHandlers) pluginLifecycles(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
items, err := h.core.ListPluginLifecyclesForSession(bearerToken(r), domain.PluginLifecycleFilter{PluginID: r.URL.Query().Get("pluginId"), ServerInstanceID: r.URL.Query().Get("serverInstanceId"), CurrentState: domain.PluginLifecycleState(r.URL.Query().Get("currentState"))})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.PluginLifecycleListFromDomain(items))
|
|
}
|
|
|
|
// pluginLifecycleAction godoc
|
|
// @Summary Dispatch a platform-mediated plugin lifecycle action
|
|
// @Description Validates manifest compatibility and creates one durable bounded Run job.
|
|
// @Tags plugin-operations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param pluginId path string true "Plugin ID"
|
|
// @Param body body dto.PluginLifecycleActionRequest true "Plugin lifecycle action"
|
|
// @Success 202 {object} dto.PluginLifecycleActionResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/plugin-lifecycles/{pluginId}/actions [post]
|
|
func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.PluginLifecycleActionRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.RunPluginLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("pluginId")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.PluginLifecycleResultFromDomain(result))
|
|
}
|
|
|
|
// authRegister godoc
|
|
// @Summary Register a platform account
|
|
// @Description Creates a pending low-privilege platform account without granting platform administrator rights.
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RegisterRequest true "Registration request"
|
|
// @Success 200 {object} dto.AuthSessionResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/register [post]
|
|
func (h *coreHandlers) authRegister(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RegisterRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
session, err := h.core.RegisterUser(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
h.writeAuthSession(w, r, session)
|
|
}
|
|
|
|
// authLogin godoc
|
|
// @Summary Login to the platform
|
|
// @Description Authenticates an active platform user and returns a bearer session token.
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.LoginRequest true "Login request"
|
|
// @Success 200 {object} dto.AuthSessionResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/login [post]
|
|
func (h *coreHandlers) authLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.LoginRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
session, err := h.core.LoginUser(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
h.writeAuthSession(w, r, session)
|
|
}
|
|
|
|
// authLogout godoc
|
|
// @Summary Logout of the platform
|
|
// @Description Invalidates the active bearer session token.
|
|
// @Tags auth
|
|
// @Produce json
|
|
// @Success 204
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/logout [post]
|
|
func (h *coreHandlers) authLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
if err := h.core.LogoutUser(bearerToken(r)); err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
h.clearSessionCookie(w, r)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// authRotate godoc
|
|
// @Summary Rotate the active platform session
|
|
// @Description Revokes the current bearer token and returns a new bounded session token.
|
|
// @Tags auth
|
|
// @Produce json
|
|
// @Success 200 {object} dto.AuthSessionResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/rotate [post]
|
|
func (h *coreHandlers) authRotate(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
session, err := h.core.RotateUserSession(bearerToken(r))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
h.writeAuthSession(w, r, session)
|
|
}
|
|
|
|
// currentUser godoc
|
|
// @Summary Get current platform user
|
|
// @Description Returns the authenticated current user's bounded identity, roles, profile, and theme preference.
|
|
// @Tags users
|
|
// @Produce json
|
|
// @Success 200 {object} dto.CurrentUserResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/users/current [get]
|
|
func (h *coreHandlers) currentUser(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
user, err := h.core.GetCurrentUser(bearerToken(r))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.CurrentUserFromDomain(user))
|
|
}
|
|
|
|
// currentUserProfile godoc
|
|
// @Summary Update current platform user profile
|
|
// @Description Updates bounded profile fields for the authenticated current user.
|
|
// @Tags users
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.UserProfileBody true "Profile update request"
|
|
// @Success 200 {object} dto.CurrentUserResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/users/current/profile [put]
|
|
func (h *coreHandlers) currentUserProfile(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
writeMethodNotAllowed(w, http.MethodPut)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.UserProfileBody](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
user, err := h.core.UpdateCurrentUserProfile(bearerToken(r), request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if request.DisplayName != "" {
|
|
user.DisplayName = request.DisplayName
|
|
user, err = h.core.UpdateUser(user.ID, user)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.CurrentUserFromDomain(user))
|
|
}
|
|
|
|
// currentUserTheme godoc
|
|
// @Summary Update current platform user theme
|
|
// @Description Persists the authenticated user's console theme preference.
|
|
// @Tags users
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.UserThemePreferenceRequest true "Theme preference request"
|
|
// @Success 200 {object} dto.UserThemePreferenceResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/users/current/theme [put]
|
|
func (h *coreHandlers) currentUserTheme(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
writeMethodNotAllowed(w, http.MethodPut)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.UserThemePreferenceRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
preference, err := h.core.UpdateCurrentUserTheme(bearerToken(r), request.ToDomain(""))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.UserThemePreferenceFromDomain(preference))
|
|
}
|
|
|
|
func bearerToken(r *http.Request) string {
|
|
const prefix = "Bearer "
|
|
header := r.Header.Get("Authorization")
|
|
if len(header) >= len(prefix) && header[:len(prefix)] == prefix {
|
|
return header[len(prefix):]
|
|
}
|
|
cookie, err := r.Cookie(platformSessionCookieName)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return cookie.Value
|
|
}
|
|
|
|
// pluginBridgeAuthorize godoc
|
|
// @Summary Authorize plugin page bridge action
|
|
// @Description Evaluates one plugin bridge action against installed plugin manifest permissions without executing privileged work.
|
|
// @Tags plugin-bridge
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.PluginBridgeAuthorizeRequest true "Plugin bridge authorization request"
|
|
// @Success 200 {object} dto.PluginBridgeAuthorizeResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/plugin-bridge/authorize [post]
|
|
func (h *coreHandlers) pluginBridgeAuthorize(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.PluginBridgeAuthorizeRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
var result domain.PluginBridgeAuthorization
|
|
if h.enforceAuthorization {
|
|
result, err = h.core.AuthorizePluginBridgeActionForSession(bearerToken(r), request.ToDomain())
|
|
} else {
|
|
result, err = h.core.AuthorizePluginBridgeAction(request.ToDomain())
|
|
}
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.PluginBridgeAuthorizeFromDomain(result))
|
|
}
|
|
|
|
// pluginBridgeExecute godoc
|
|
// @Summary Execute plugin page bridge action
|
|
// @Description Authorizes and executes one platform-mediated plugin page bridge action without exposing platform auth, run sockets, host paths, or provider credentials.
|
|
// @Tags plugin-bridge
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.PluginBridgeExecuteRequest true "Plugin bridge execution request"
|
|
// @Success 200 {object} dto.PluginBridgeExecuteResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/plugin-bridge/execute [post]
|
|
func (h *coreHandlers) pluginBridgeExecute(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.PluginBridgeExecuteRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ExecutePluginBridgeAction(bearerToken(r), request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.PluginBridgeExecuteFromDomain(result))
|
|
}
|
|
|
|
// users godoc
|
|
// @Summary Create or list users
|
|
// @Description Creates a platform user or lists platform users.
|
|
// @Tags users
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.UserCreateRequest false "User create request"
|
|
// @Success 200 {object} dto.UserListResponse
|
|
// @Success 201 {object} dto.UserResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/users [get]
|
|
// @Router /api/v1/users [post]
|
|
func (h *coreHandlers) users(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
users, err := h.core.ListUsers(domain.UserFilter{Status: domain.UserStatus(r.URL.Query().Get("status"))})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.UserListFromDomain(users))
|
|
case http.MethodPost:
|
|
request, err := decodeJSON[dto.UserCreateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
user, err := h.core.CreateUser(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.UserFromDomain(user))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, POST")
|
|
}
|
|
}
|
|
|
|
// userDetail godoc
|
|
// @Summary Get or update user
|
|
// @Description Returns or updates one platform user by ID.
|
|
// @Tags users
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "User ID"
|
|
// @Param body body dto.UserUpdateRequest false "User update request"
|
|
// @Success 200 {object} dto.UserResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/users/{id} [get]
|
|
// @Router /api/v1/users/{id} [put]
|
|
func (h *coreHandlers) userDetail(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
user, err := h.core.GetUser(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.UserFromDomain(user))
|
|
case http.MethodPut:
|
|
existing, err := h.core.GetUser(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.UserUpdateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
user, err := h.core.UpdateUser(r.PathValue("id"), request.ApplyTo(existing))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.UserFromDomain(user))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, PUT")
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *coreHandlers) requirePlatformAdmin(w http.ResponseWriter, r *http.Request) (domain.User, bool) {
|
|
user, err := h.core.GetCurrentUser(bearerToken(r))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return domain.User{}, false
|
|
}
|
|
for _, role := range user.Roles {
|
|
switch role {
|
|
case "platform-admin", "admin":
|
|
return user, true
|
|
}
|
|
}
|
|
writeServiceError(w, service.ErrForbidden)
|
|
return domain.User{}, false
|
|
}
|
|
|
|
// aiProviders godoc
|
|
// @Summary Create or list AI providers
|
|
// @Description Creates or lists platform-managed AI provider metadata without raw keys.
|
|
// @Tags ai-providers
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.AIProviderCreateRequest false "AI provider create request"
|
|
// @Success 200 {object} dto.AIProviderListResponse
|
|
// @Success 201 {object} dto.AIProviderResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/ai-providers [get]
|
|
// @Router /api/v1/ai-providers [post]
|
|
func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
providers, err := h.core.ListAIProviders(domain.AIProviderFilter{
|
|
Kind: domain.AIProviderKind(r.URL.Query().Get("kind")),
|
|
Status: domain.AIProviderStatus(r.URL.Query().Get("status")),
|
|
})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.AIProviderListFromDomain(providers))
|
|
case http.MethodPost:
|
|
request, err := decodeJSON[dto.AIProviderCreateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
provider, err := h.core.CreateAIProvider(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.AIProviderFromDomain(provider))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, POST")
|
|
}
|
|
}
|
|
|
|
// aiProviderDetail godoc
|
|
// @Summary Get AI provider
|
|
// @Description Returns one platform-managed AI provider by ID without raw key or base URL material.
|
|
// @Tags ai-providers
|
|
// @Produce json
|
|
// @Param id path string true "AI provider ID"
|
|
// @Success 200 {object} dto.AIProviderResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/ai-providers/{id} [get]
|
|
// @Router /api/v1/ai-providers/{id} [put]
|
|
func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
provider, err := h.core.GetAIProvider(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.AIProviderFromDomain(provider))
|
|
case http.MethodPut:
|
|
existing, err := h.core.GetAIProvider(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.AIProviderUpdateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
update := request.ToDomain(r.PathValue("id"), existing.Status)
|
|
if strings.TrimSpace(update.BaseURL) == "" {
|
|
update.BaseURL = existing.BaseURL
|
|
}
|
|
if strings.TrimSpace(update.APIKeyRef) == "" {
|
|
update.APIKeyRef = existing.APIKeyRef
|
|
}
|
|
provider, err := h.core.UpdateAIProvider(r.PathValue("id"), update)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.AIProviderFromDomain(provider))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, PUT")
|
|
}
|
|
}
|
|
|
|
// aiProviderStatus godoc
|
|
// @Summary Set AI provider status
|
|
// @Description Enables or disables one AI provider without exposing raw key material.
|
|
// @Tags ai-providers
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "AI provider ID"
|
|
// @Param body body dto.AIProviderStatusRequest true "AI provider status request"
|
|
// @Success 200 {object} dto.AIProviderResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/ai-providers/{id}/status [post]
|
|
func (h *coreHandlers) aiProviderStatus(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.AIProviderStatusRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
provider, err := h.core.SetAIProviderStatus(r.PathValue("id"), request.Status)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.AIProviderFromDomain(provider))
|
|
}
|
|
|
|
// aiProviderTest godoc
|
|
// @Summary Test AI provider metadata
|
|
// @Description Performs local metadata validation for one AI provider without external network calls.
|
|
// @Tags ai-providers
|
|
// @Produce json
|
|
// @Param id path string true "AI provider ID"
|
|
// @Success 200 {object} dto.AIProviderTestResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/ai-providers/{id}/test [post]
|
|
func (h *coreHandlers) aiProviderTest(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
result, err := h.core.TestAIProvider(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.AIProviderTestFromDomain(result))
|
|
}
|
|
|
|
// aiProviderModels godoc
|
|
// @Summary List AI provider configured models
|
|
// @Description Returns configured model names for one AI provider without exposing credentials.
|
|
// @Tags ai-providers
|
|
// @Produce json
|
|
// @Param id path string true "AI provider ID"
|
|
// @Success 200 {object} dto.AIProviderModelsResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/ai-providers/{id}/models [get]
|
|
func (h *coreHandlers) aiProviderModels(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
models, err := h.core.ListAIProviderModels(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.AIProviderModelsFromDomain(models))
|
|
}
|
|
|
|
// aiInvocation godoc
|
|
// @Summary Invoke platform-mediated AI
|
|
// @Description Invokes AI through platform-owned provider metadata and returns redacted recommendations without exposing provider credentials.
|
|
// @Tags ai
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.AIInvocationRequest true "AI invocation request"
|
|
// @Success 200 {object} dto.AIInvocationResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/ai/invocations [post]
|
|
func (h *coreHandlers) aiInvocation(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.AIInvocationRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
response, err := h.core.InvokeAIForSession(bearerToken(r), request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.AIInvocationFromDomain(response))
|
|
}
|
|
|
|
func (h *coreHandlers) aiConfigSuggestion(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.LlmConfigSuggestionRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
response, err := h.core.InvokeAIForSession(bearerToken(r), domain.AIInvocationRequest{
|
|
RequestID: fmt.Sprintf("config-suggestion:%s:%d", request.ServerInstanceID, time.Now().UnixNano()),
|
|
ServerInstanceID: request.ServerInstanceID,
|
|
Purpose: "config.suggest",
|
|
Prompt: request.Prompt,
|
|
CurrentConfig: request.CurrentConfig,
|
|
ContextRefs: map[string]string{"server": "server://" + request.ServerInstanceID},
|
|
})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if response.Status != "ok" {
|
|
recommendation := "AI suggestion unavailable"
|
|
if response.Error != nil {
|
|
recommendation = response.Error.Message
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: recommendation})
|
|
return
|
|
}
|
|
suggested := ""
|
|
if response.ConfigRecommendation != nil {
|
|
suggested = response.ConfigRecommendation.SuggestedConfig
|
|
}
|
|
var execution *dto.AIConfigExecutionResponse
|
|
if response.ConfigExecution != nil {
|
|
execution = &dto.AIConfigExecutionResponse{Status: response.ConfigExecution.Status, Job: dto.JobFromDomain(response.ConfigExecution.Job)}
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested, ConfigExecution: execution})
|
|
}
|
|
|
|
// gamePlugins godoc
|
|
// @Summary Create or list game management plugins
|
|
// @Description Creates or lists installed game management plugin metadata.
|
|
// @Tags game-plugins
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.GamePluginCreateRequest false "Game plugin create request"
|
|
// @Success 200 {object} dto.GamePluginListResponse
|
|
// @Success 201 {object} dto.GamePluginResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/game-plugins [get]
|
|
// @Router /api/v1/game-plugins [post]
|
|
func (h *coreHandlers) gamePlugins(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
plugins, err := h.core.ListGamePlugins(domain.GamePluginFilter{
|
|
ServerType: r.URL.Query().Get("serverType"),
|
|
Status: domain.GamePluginStatus(r.URL.Query().Get("status")),
|
|
})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.GamePluginListFromDomain(plugins))
|
|
case http.MethodPost:
|
|
request, err := decodeJSON[dto.GamePluginCreateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
if violations := request.RuntimeProfiles.UnsupportedLegacyProfileViolations("runtimeProfiles"); len(violations) > 0 {
|
|
writeServiceError(w, validator.ValidationError{Violations: violations})
|
|
return
|
|
}
|
|
plugin, err := h.core.CreateGamePlugin(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.GamePluginFromDomain(plugin))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, POST")
|
|
}
|
|
}
|
|
|
|
// gamePluginManifestRegistration godoc
|
|
// @Summary Register game management plugin manifest
|
|
// @Description Validates and registers one game management plugin manifest as installed registry metadata, refreshing an existing manifest with the same ID in place.
|
|
// @Tags game-plugins
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.GamePluginManifestRegistrationRequest true "Game plugin manifest registration request"
|
|
// @Success 201 {object} dto.GamePluginResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/game-plugins/register-manifest [post]
|
|
func (h *coreHandlers) gamePluginManifestRegistration(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.GamePluginManifestRegistrationRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
if violations := request.Manifest.RuntimeProfiles.UnsupportedLegacyProfileViolations("manifest.runtimeProfiles"); len(violations) > 0 {
|
|
writeServiceError(w, validator.ValidationError{Violations: violations})
|
|
return
|
|
}
|
|
plugin, err := h.core.RegisterGamePluginManifest(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.GamePluginFromDomain(plugin))
|
|
}
|
|
|
|
// gamePluginDetail godoc
|
|
// @Summary Get game management plugin
|
|
// @Description Returns one game management plugin by ID.
|
|
// @Tags game-plugins
|
|
// @Produce json
|
|
// @Param id path string true "Game plugin ID"
|
|
// @Success 200 {object} dto.GamePluginResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/game-plugins/{id} [get]
|
|
func (h *coreHandlers) gamePluginDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
plugin, err := h.core.GetGamePlugin(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.GamePluginFromDomain(plugin))
|
|
}
|
|
|
|
// marketplacePlugins godoc
|
|
// @Summary List plugin marketplace entries
|
|
// @Description Lists marketplace plugin metadata projected from the platform registry without commerce data, host paths, run sockets, or raw credentials.
|
|
// @Tags plugin-marketplace
|
|
// @Produce json
|
|
// @Param status query string false "Plugin status"
|
|
// @Param serverType query string false "Server type"
|
|
// @Param capability query string false "Run or bridge capability"
|
|
// @Param keyword query string false "Keyword search"
|
|
// @Success 200 {object} dto.MarketplacePluginListResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/plugin-marketplace/plugins [get]
|
|
func (h *coreHandlers) marketplacePlugins(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
plugins, err := h.core.ListMarketplacePlugins(domain.PluginMarketplaceFilter{
|
|
ServerType: r.URL.Query().Get("serverType"),
|
|
Status: domain.GamePluginStatus(r.URL.Query().Get("status")),
|
|
Capability: r.URL.Query().Get("capability"),
|
|
Keyword: r.URL.Query().Get("keyword"),
|
|
})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.MarketplacePluginListFromDomain(plugins))
|
|
}
|
|
|
|
// marketplacePluginDetail godoc
|
|
// @Summary Get plugin marketplace detail
|
|
// @Description Returns one plugin marketplace entry with manifest-backed metadata and redacted platform-safe fields.
|
|
// @Tags plugin-marketplace
|
|
// @Produce json
|
|
// @Param id path string true "Plugin ID"
|
|
// @Success 200 {object} dto.MarketplacePluginResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/plugin-marketplace/plugins/{id} [get]
|
|
func (h *coreHandlers) marketplacePluginDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
plugin, err := h.core.GetMarketplacePlugin(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.MarketplacePluginFromDomain(plugin))
|
|
}
|
|
|
|
// marketplacePluginState godoc
|
|
// @Summary Change plugin marketplace state
|
|
// @Description Applies metadata-only install, enable, or disable state changes without package download or run execution.
|
|
// @Tags plugin-marketplace
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Plugin ID"
|
|
// @Param body body dto.MarketplacePluginStateRequest true "Marketplace state action"
|
|
// @Success 200 {object} dto.MarketplacePluginResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/plugin-marketplace/plugins/{id}/state [post]
|
|
func (h *coreHandlers) marketplacePluginState(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.MarketplacePluginStateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
plugin, err := h.core.SetMarketplacePluginState(r.PathValue("id"), request.Action)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.MarketplacePluginFromDomain(plugin))
|
|
}
|
|
|
|
// serverInstances godoc
|
|
// @Summary Create or list server instances
|
|
// @Description Creates or lists server instances linked to installed game plugins and run endpoints.
|
|
// @Tags server-instances
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.ServerInstanceCreateRequest false "Server instance create request"
|
|
// @Success 200 {object} dto.ServerInstanceListResponse
|
|
// @Success 201 {object} dto.ServerInstanceResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/server-instances [get]
|
|
// @Router /api/v1/server-instances [post]
|
|
func (h *coreHandlers) serverInstances(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
instances, err := h.core.ListServerInstancesForSession(bearerToken(r), domain.ServerInstanceFilter{
|
|
PluginID: r.URL.Query().Get("pluginId"),
|
|
RunEndpointID: r.URL.Query().Get("runEndpointId"),
|
|
State: domain.ServerInstanceState(r.URL.Query().Get("state")),
|
|
})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerInstanceListFromDomain(instances))
|
|
case http.MethodPost:
|
|
request, err := decodeJSON[dto.ServerInstanceCreateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
instance, err := h.core.CreateServerInstanceForSession(bearerToken(r), request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.ServerInstanceFromDomain(instance))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, POST")
|
|
}
|
|
}
|
|
|
|
// serverInstanceDetail godoc
|
|
// @Summary Get, update, or delete server instance
|
|
// @Description Returns one server instance by ID, updates safe metadata, or deletes it by marking the instance deleted after safety validation and password confirmation. Delete requests send a JSON body with the current password and may include explicit forced-delete confirmation for running or installing instances.
|
|
// @Tags server-instances
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Server instance ID"
|
|
// @Param body body dto.ServerDeletionRequest false "Server metadata update or deletion request"
|
|
// @Success 204
|
|
// @Success 200 {object} dto.ServerInstanceResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/server-instances/{id} [get]
|
|
// @Router /api/v1/server-instances/{id} [put]
|
|
// @Router /api/v1/server-instances/{id} [delete]
|
|
func (h *coreHandlers) serverInstanceDetail(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
|
case http.MethodPut:
|
|
request, err := decodeJSON[dto.ServerInstanceUpdateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
instance, err := h.core.UpdateServerInstanceForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
|
case http.MethodDelete:
|
|
request, err := decodeJSON[dto.ServerDeletionRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
_, err = h.core.DeleteServerInstanceForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
writeMethodNotAllowed(w, http.MethodGet+", "+http.MethodPut+", "+http.MethodDelete)
|
|
}
|
|
}
|
|
|
|
// platformMetrics godoc
|
|
// @Summary Get platform resource usage metrics
|
|
// @Description Returns bounded platform resource usage derived from platform metadata without host paths or secrets.
|
|
// @Tags metrics
|
|
// @Produce json
|
|
// @Success 200 {object} dto.PlatformResourceUsageResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/metrics/platform [get]
|
|
func (h *coreHandlers) platformMetrics(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
usage, err := h.core.GetPlatformResourceUsage()
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.PlatformResourceUsageFromDomain(usage))
|
|
}
|
|
|
|
// serverInstanceMetrics godoc
|
|
// @Summary List visible server metrics
|
|
// @Description Returns bounded metrics for server instances visible to the authenticated user.
|
|
// @Tags metrics
|
|
// @Produce json
|
|
// @Success 200 {object} dto.ServerMetricsListResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/metrics/server-instances [get]
|
|
func (h *coreHandlers) serverInstanceMetrics(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
metrics, err := h.core.ListServerMetricsForSession(bearerToken(r))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerMetricsListFromDomain(metrics))
|
|
}
|
|
|
|
// serverInstanceConfig is kept as legacy service plumbing but is not registered as a browser product route.
|
|
func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
config, err := h.core.GetServerConfigForSession(bearerToken(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config))
|
|
}
|
|
|
|
// serverDeclaredFileReadSnapshot is kept as legacy service plumbing but is not registered as a browser product route.
|
|
func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
|
|
}
|
|
|
|
// serverInstanceConfigDiff is kept as legacy service plumbing but is not registered as a browser product route.
|
|
func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ServerConfigDiffPreviewRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
preview, err := h.core.PreviewServerConfigWriteForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerConfigDiffPreviewFromDomain(preview))
|
|
}
|
|
|
|
// serverInstanceConfigApprove is kept as legacy service plumbing but is not registered as a browser product route.
|
|
func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ServerConfigWriteApprovalRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
dispatch, err := h.core.ApproveServerConfigWriteForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.ServerConfigWriteDispatchFromDomain(dispatch))
|
|
}
|
|
|
|
// serverFilesWorkspace godoc
|
|
// @Summary Read server file workspace
|
|
// @Description Returns the generic server file manager root and transfer policy without exposing host paths.
|
|
// @Tags server-files
|
|
// @Produce json
|
|
// @Param id path string true "Server instance ID"
|
|
// @Success 200 {object} dto.ServerFileWorkspaceResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/server-instances/{id}/files/workspace [get]
|
|
func (h *coreHandlers) serverFilesWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerFileWorkspaceFromDomain(workspace))
|
|
}
|
|
|
|
func (h *coreHandlers) serverFilesList(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
directoryKey, err := h.serverFileDirectoryKey(r, r.URL.Query().Get("directoryKey"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ListServerFilesForSession(bearerToken(r), domain.ServerFileListRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: directoryKey, Path: r.URL.Query().Get("path"), Query: r.URL.Query().Get("query"), Recursive: r.URL.Query().Get("recursive") == "true"})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerFileListFromDomain(result))
|
|
}
|
|
|
|
// serverFilesBrowse godoc
|
|
// @Summary Browse one live server directory
|
|
// @Description Dispatches a fresh Run files.list job for the requested logical directory and briefly waits for the matching result without exposing host paths or stale cached listings.
|
|
// @Tags server-files
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Server instance ID"
|
|
// @Param body body dto.ServerFileListRequest true "Server file browse request"
|
|
// @Success 202 {object} dto.ServerFileListResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/server-instances/{id}/files/browse [post]
|
|
func (h *coreHandlers) serverFilesBrowse(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ServerFileListRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
request.DirectoryKey, err = h.serverFileDirectoryKey(r, request.DirectoryKey)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.BrowseServerFilesForSession(r.Context(), bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.ServerFileListFromDomain(result))
|
|
}
|
|
|
|
func (h *coreHandlers) serverFilesRefresh(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ServerFileListRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
request.DirectoryKey, err = h.serverFileDirectoryKey(r, request.DirectoryKey)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.RefreshServerFileListForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.ServerFileListFromDomain(result))
|
|
}
|
|
|
|
func (h *coreHandlers) serverFilesReadSnapshot(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
|
|
}
|
|
|
|
func (h *coreHandlers) serverFilesRead(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ServerFileReadRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ReadServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
|
|
}
|
|
|
|
func (h *coreHandlers) serverFilesWrite(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ServerFileWriteRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.WriteServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
|
|
}
|
|
|
|
func (h *coreHandlers) serverFilesUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
upload, err := h.decodeServerFileUpload(r)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.UploadServerFileForSession(bearerToken(r), upload)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.ServerFileUploadFromDomain(result))
|
|
}
|
|
|
|
func (h *coreHandlers) serverFilesDownload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ServerFileDownloadRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.PrepareServerFileDownloadForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.ServerFileDownloadFromDomain(result))
|
|
}
|
|
|
|
func (h *coreHandlers) serverFileDirectoryKey(r *http.Request, requested string) (string, error) {
|
|
requested = strings.TrimSpace(requested)
|
|
if requested != "" {
|
|
return requested, nil
|
|
}
|
|
workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return workspace.DefaultDirectoryKey, nil
|
|
}
|
|
|
|
func (h *coreHandlers) decodeServerFileUpload(r *http.Request) (domain.ServerFileUploadRequest, error) {
|
|
if err := r.ParseMultipartForm(8 * 1024 * 1024); err != nil {
|
|
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"multipart upload is invalid"}}
|
|
}
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"file is required"}}
|
|
}
|
|
defer file.Close()
|
|
payload, err := io.ReadAll(io.LimitReader(file, validator.MaxArtifactBytes+1))
|
|
if err != nil {
|
|
return domain.ServerFileUploadRequest{}, err
|
|
}
|
|
if int64(len(payload)) > validator.MaxArtifactBytes {
|
|
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"payload is too large"}}
|
|
}
|
|
checksum := strings.TrimSpace(r.FormValue("checksum"))
|
|
if checksum == "" {
|
|
checksum = validator.BytesChecksum(payload)
|
|
}
|
|
filename := strings.TrimSpace(r.FormValue("filename"))
|
|
if filename == "" && header != nil {
|
|
filename = header.Filename
|
|
}
|
|
return domain.ServerFileUploadRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: r.FormValue("directoryKey"), RelativePath: r.FormValue("relativePath"), Filename: filename, Payload: payload, Checksum: checksum, IdempotencyKey: r.FormValue("idempotencyKey")}, nil
|
|
}
|
|
|
|
// fileOperationDispatch godoc
|
|
// @Summary Dispatch scoped file operation
|
|
// @Description Queues a scoped files.read or files.write job using logical file keys or refs, never raw host paths.
|
|
// @Tags file-operations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.FileOperationDispatchRequest true "File operation dispatch request"
|
|
// @Success 202 {object} dto.FileOperationDispatchResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/file-operations/dispatch [post]
|
|
func (h *coreHandlers) fileOperationDispatch(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.FileOperationDispatchRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.DispatchFileOperationForSession(bearerToken(r), request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
|
|
}
|
|
|
|
// serverAdministratorCandidates godoc
|
|
// @Summary List server administrator invite candidates
|
|
// @Description Returns active non-platform-admin users that the authenticated server owner can invite for one server.
|
|
// @Tags server-instances
|
|
// @Produce json
|
|
// @Param id path string true "Server instance ID"
|
|
// @Success 200 {object} dto.ServerMemberListResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/server-instances/{id}/administrators/candidates [get]
|
|
func (h *coreHandlers) serverAdministratorCandidates(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
users, err := h.core.ListServerAdministratorCandidates(bearerToken(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerMemberListFromDomain(users))
|
|
}
|
|
|
|
// serverAdministrators godoc
|
|
// @Summary Add server administrator
|
|
// @Description Adds an active non-platform-admin user as a server-scoped administrator when requested by the server owner.
|
|
// @Tags server-instances
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Server instance ID"
|
|
// @Param body body dto.ServerMemberRequest true "Server administrator request"
|
|
// @Success 200 {object} dto.ServerInstanceResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/server-instances/{id}/administrators [post]
|
|
func (h *coreHandlers) serverAdministrators(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ServerMemberRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
instance, err := h.core.AddServerAdministrator(bearerToken(r), r.PathValue("id"), request.UserID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
|
}
|
|
|
|
// serverAdministratorDetail godoc
|
|
// @Summary Remove server administrator
|
|
// @Description Removes a server-scoped administrator from a server when requested by the server owner.
|
|
// @Tags server-instances
|
|
// @Produce json
|
|
// @Param id path string true "Server instance ID"
|
|
// @Param userId path string true "User ID"
|
|
// @Success 200 {object} dto.ServerInstanceResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/server-instances/{id}/administrators/{userId} [delete]
|
|
func (h *coreHandlers) serverAdministratorDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
writeMethodNotAllowed(w, http.MethodDelete)
|
|
return
|
|
}
|
|
instance, err := h.core.RemoveServerAdministrator(bearerToken(r), r.PathValue("id"), r.PathValue("userId"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
|
}
|
|
|
|
// runControlHello godoc
|
|
// @Summary Register run control session
|
|
// @Description Accepts lightweight run hello metadata, creates or updates run endpoint metadata, and returns a platform-issued session token.
|
|
// @Tags run
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunControlHelloRequest true "Run control hello request"
|
|
// @Success 200 {object} dto.RunControlHelloResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/control/hello [post]
|
|
func (h *coreHandlers) runControlHello(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunControlHelloRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.RegisterRunHello(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunControlHelloFromDomain(result))
|
|
}
|
|
|
|
// runControlHeartbeat godoc
|
|
// @Summary Accept run control heartbeat
|
|
// @Description Accepts lightweight run heartbeat metadata when the active platform-issued session token matches.
|
|
// @Tags run
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunControlHeartbeatRequest true "Run control heartbeat request"
|
|
// @Success 200 {object} dto.RunControlHeartbeatResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/control/heartbeat [post]
|
|
func (h *coreHandlers) runControlHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunControlHeartbeatRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.AcceptRunHeartbeat(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if err := h.core.ReconcileSCUMQueryTemplatesForRunEndpoint(request.RunEndpointID); err != nil {
|
|
log.Printf("SCUM query template dispatch skipped run_endpoint=%s error=%s", request.RunEndpointID, err.Error())
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result))
|
|
}
|
|
|
|
// runControlEvents godoc
|
|
// @Summary Stream lightweight Run control events
|
|
// @Description Opens a signed Run-only event stream for wakeups such as queued job changes. Events never carry job payloads, logs, artifacts, host paths, or credentials.
|
|
// @Tags run
|
|
// @Accept json
|
|
// @Produce text/event-stream
|
|
// @Param body body dto.RunControlStreamRequest true "Run control stream request"
|
|
// @Success 200 {object} dto.RunControlEventResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/control/events [post]
|
|
func (h *coreHandlers) runControlEvents(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunControlStreamRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
subscription, err := h.core.SubscribeRunControlEvents(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
defer subscription.Cancel()
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeServiceError(w, validator.ValidationError{Violations: []string{"streaming response is unavailable"}})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
lastSeq := request.LastEventSeq
|
|
ready := dto.RunControlEventResponse{RunEndpointID: request.RunEndpointID, Sequence: lastSeq, Type: domain.RunControlEventTypeReady, ServerTime: time.Now().UTC(), RetrySeconds: 5}
|
|
if err := writeRunControlSSE(w, flusher, ready); err != nil {
|
|
return
|
|
}
|
|
if subscription.Initial != nil {
|
|
event := dto.RunControlEventFromDomain(*subscription.Initial)
|
|
if event.Sequence > lastSeq {
|
|
lastSeq = event.Sequence
|
|
}
|
|
if err := writeRunControlSSE(w, flusher, event); err != nil {
|
|
return
|
|
}
|
|
}
|
|
heartbeatTicker := time.NewTicker(15 * time.Second)
|
|
defer heartbeatTicker.Stop()
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case event, ok := <-subscription.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
response := dto.RunControlEventFromDomain(event)
|
|
if response.Sequence > lastSeq {
|
|
lastSeq = response.Sequence
|
|
}
|
|
if err := writeRunControlSSE(w, flusher, response); err != nil {
|
|
return
|
|
}
|
|
case <-heartbeatTicker.C:
|
|
heartbeat := dto.RunControlEventResponse{RunEndpointID: request.RunEndpointID, Sequence: lastSeq, Type: domain.RunControlEventTypeHeartbeat, ServerTime: time.Now().UTC(), RetrySeconds: 5}
|
|
if err := writeRunControlSSE(w, flusher, heartbeat); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeRunControlSSE(w http.ResponseWriter, flusher http.Flusher, event dto.RunControlEventResponse) error {
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if event.Type != "" {
|
|
if _, err := fmt.Fprintf(w, "event: %s\n", event.Type); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := fmt.Fprintf(w, "id: %d\n", event.Sequence); err != nil {
|
|
return err
|
|
}
|
|
if event.RetrySeconds > 0 {
|
|
if _, err := fmt.Fprintf(w, "retry: %d\n", event.RetrySeconds*1000); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
|
|
return err
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
}
|
|
|
|
// runLifecycleReport godoc
|
|
// @Summary Report autonomous run lifecycle result
|
|
// @Description Lets a registered run endpoint report an observed lifecycle terminal result without a platform-assigned job lease.
|
|
// @Tags run
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunLifecycleReportRequest true "Run lifecycle report request"
|
|
// @Success 200 {object} dto.RunLifecycleReportResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/lifecycle/report [post]
|
|
func (h *coreHandlers) runLifecycleReport(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunLifecycleReportRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ReportRunLifecycle(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunLifecycleReportFromDomain(result))
|
|
}
|
|
|
|
// runJobClaim godoc
|
|
// @Summary Claim one run job
|
|
// @Description Lets a registered run endpoint claim one queued job assigned to it using the active session token.
|
|
// @Tags run-jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunJobClaimRequest true "Run job claim request"
|
|
// @Success 200 {object} dto.RunJobClaimResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/jobs/claim [post]
|
|
func (h *coreHandlers) runJobClaim(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunJobClaimRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ClaimRunJobWithWait(r.Context(), request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunJobClaimFromDomain(result))
|
|
}
|
|
|
|
// runJobAck godoc
|
|
// @Summary Acknowledge one run job
|
|
// @Description Lets a registered run endpoint acknowledge an active job lease before execution.
|
|
// @Tags run-jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunJobAckRequest true "Run job ack request"
|
|
// @Success 200 {object} dto.RunJobAckResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/jobs/ack [post]
|
|
func (h *coreHandlers) runJobAck(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunJobAckRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.AckRunJob(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunJobAckFromDomain(result))
|
|
}
|
|
|
|
// runJobProgress godoc
|
|
// @Summary Update run job progress
|
|
// @Description Lets a registered run endpoint report bounded progress for an active job lease.
|
|
// @Tags run-jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunJobProgressRequest true "Run job progress request"
|
|
// @Success 200 {object} dto.RunJobProgressResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/jobs/progress [post]
|
|
func (h *coreHandlers) runJobProgress(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunJobProgressRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.UpdateRunJobProgress(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunJobProgressFromDomain(result))
|
|
}
|
|
|
|
// runJobResult godoc
|
|
// @Summary Complete run job
|
|
// @Description Lets a registered run endpoint submit a bounded terminal result for an active job lease.
|
|
// @Tags run-jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunJobResultRequest true "Run job result request"
|
|
// @Success 200 {object} dto.RunJobResultResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/jobs/result [post]
|
|
func (h *coreHandlers) runJobResult(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunJobResultRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.CompleteRunJob(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunJobResultFromDomain(result))
|
|
}
|
|
|
|
// runJobBuildInput returns secret-bearing build input only to the active leased run worker.
|
|
func (h *coreHandlers) runJobBuildInput(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.DistributionBuildInputRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.GetDistributionBuildInput(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.DistributionBuildInputFromDomain(result))
|
|
}
|
|
|
|
// runJobDependencyInput returns declared and resolved dependency input only to the active fenced Run attempt.
|
|
func (h *coreHandlers) runJobDependencyInput(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.DependencyExecutionInputRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.GetDependencyExecutionInput(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.DependencyExecutionInputFromDomain(result))
|
|
}
|
|
|
|
// runJobUpdateInput returns update metadata only to the active fenced Run attempt.
|
|
func (h *coreHandlers) runJobUpdateInput(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunUpdateInputRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.GetRunUpdateInput(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunUpdateInputFromDomain(result))
|
|
}
|
|
|
|
// runJobUpdateChunk serves one bounded update range only to the active fenced Run attempt.
|
|
func (h *coreHandlers) runJobUpdateChunk(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunUpdateChunkRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ReadRunUpdateChunk(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(result))
|
|
}
|
|
|
|
// runFileInputChunk serves one bounded browser-uploaded file input range only to the active fenced Run attempt.
|
|
func (h *coreHandlers) runFileInputChunk(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunFileInputChunkRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ReadRunFileInputChunk(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunFileInputChunkFromDomain(result))
|
|
}
|
|
|
|
// runJobUpdateHealth godoc
|
|
// @Summary Confirm a reconciled Run self-update outcome
|
|
// @Description Accepts a signed current-session health or rollback report fenced to the terminal update job attempt.
|
|
// @Tags run-jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunUpdateHealthRequest true "Run update health report"
|
|
// @Success 200 {object} dto.RunUpdateHealthResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/jobs/update-health [post]
|
|
func (h *coreHandlers) runJobUpdateHealth(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunUpdateHealthRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ReportRunUpdateHealth(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunUpdateHealthFromDomain(result))
|
|
}
|
|
|
|
// runJobCancelPoll godoc
|
|
// @Summary Poll run job cancellation
|
|
// @Description Lets a registered run endpoint poll for cancellation requests on active leased jobs.
|
|
// @Tags run-jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunJobCancelPollRequest true "Run job cancel poll request"
|
|
// @Success 200 {object} dto.RunJobCancelPollResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/jobs/cancel [post]
|
|
func (h *coreHandlers) runJobCancelPoll(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunJobCancelPollRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.PollRunJobCancel(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunJobCancelPollFromDomain(result))
|
|
}
|
|
|
|
// runJobReconcile godoc
|
|
// @Summary Reconcile run jobs
|
|
// @Description Lets a registered run endpoint reconcile active platform jobs after restart or reconnect.
|
|
// @Tags run-jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunJobReconcileRequest true "Run job reconcile request"
|
|
// @Success 200 {object} dto.RunJobReconcileResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/jobs/reconcile [post]
|
|
func (h *coreHandlers) runJobReconcile(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunJobReconcileRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ReconcileRunJobs(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunJobReconcileFromDomain(result))
|
|
}
|
|
|
|
// runLogBatchIngest godoc
|
|
// @Summary Ingest run log batch
|
|
// @Description Accepts one bounded durable log batch from a registered run endpoint and returns an acknowledgement range.
|
|
// @Tags run-logs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.LogBatchIngestRequest true "Log batch ingest request"
|
|
// @Success 200 {object} dto.LogBatchIngestResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/logs/batches [post]
|
|
func (h *coreHandlers) runLogBatchIngest(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.LogBatchIngestRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.IngestLogBatch(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.LogBatchIngestFromDomain(result))
|
|
}
|
|
|
|
func (h *coreHandlers) runLogStreamProgress(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunLogStreamProgressRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.GetRunLogStreamProgress(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunLogStreamProgressFromDomain(result))
|
|
}
|
|
|
|
// runArtifactOpen godoc
|
|
// @Summary Open run artifact upload transfer
|
|
// @Description Lets a registered run endpoint open a resumable upload transfer for a scoped artifact owner.
|
|
// @Tags run-artifacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.ArtifactTransferOpenRequest true "Artifact transfer open request"
|
|
// @Success 200 {object} dto.ArtifactTransferOpenResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/artifacts/open [post]
|
|
func (h *coreHandlers) runArtifactOpen(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ArtifactTransferOpenRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.OpenArtifactTransfer(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ArtifactTransferOpenFromDomain(result))
|
|
}
|
|
|
|
// runArtifactChunkUpload godoc
|
|
// @Summary Upload run artifact chunk
|
|
// @Description Accepts one bounded artifact chunk from a registered run endpoint and returns resumable acknowledgement state.
|
|
// @Tags run-artifacts
|
|
// @Accept octet-stream
|
|
// @Produce json
|
|
// @Param X-Artifact-Transfer-Id header string true "Artifact transfer ID"
|
|
// @Param X-Artifact-Id header string true "Artifact ID"
|
|
// @Param X-Artifact-Chunk-Index header int true "Chunk index"
|
|
// @Param X-Artifact-Offset header int true "Byte offset"
|
|
// @Param X-Artifact-Size header int true "Chunk size"
|
|
// @Param X-Artifact-Checksum header string true "Chunk sha256 checksum"
|
|
// @Param body body file true "Artifact chunk bytes"
|
|
// @Success 200 {object} dto.ArtifactChunkUploadResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/artifacts/chunks [post]
|
|
func (h *coreHandlers) runArtifactChunkUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeArtifactChunkUploadRequest(r)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.UploadArtifactChunk(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ArtifactChunkUploadFromDomain(result))
|
|
}
|
|
|
|
const (
|
|
artifactTransferIDHeader = "X-Artifact-Transfer-Id"
|
|
artifactIDHeader = "X-Artifact-Id"
|
|
artifactChunkIndexHeader = "X-Artifact-Chunk-Index"
|
|
artifactChunkOffsetHeader = "X-Artifact-Offset"
|
|
artifactChunkSizeHeader = "X-Artifact-Size"
|
|
artifactChunkHashHeader = "X-Artifact-Checksum"
|
|
)
|
|
|
|
func decodeArtifactChunkUploadRequest(r *http.Request) (dto.ArtifactChunkUploadRequest, error) {
|
|
if !isOctetStream(r.Header.Get("Content-Type")) {
|
|
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{"contentType must be application/octet-stream"}}
|
|
}
|
|
chunkIndex, err := parseRequiredIntHeader(r, artifactChunkIndexHeader)
|
|
if err != nil {
|
|
return dto.ArtifactChunkUploadRequest{}, err
|
|
}
|
|
offset, err := parseRequiredInt64Header(r, artifactChunkOffsetHeader)
|
|
if err != nil {
|
|
return dto.ArtifactChunkUploadRequest{}, err
|
|
}
|
|
sizeBytes, err := parseRequiredIntHeader(r, artifactChunkSizeHeader)
|
|
if err != nil {
|
|
return dto.ArtifactChunkUploadRequest{}, err
|
|
}
|
|
if sizeBytes <= 0 || sizeBytes > validator.MaxArtifactChunkBytes {
|
|
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{fmt.Sprintf("%s must be between 1 and %d", artifactChunkSizeHeader, validator.MaxArtifactChunkBytes)}}
|
|
}
|
|
payload, err := io.ReadAll(io.LimitReader(r.Body, int64(validator.MaxArtifactChunkBytes)+1))
|
|
if err != nil {
|
|
return dto.ArtifactChunkUploadRequest{}, err
|
|
}
|
|
if len(payload) != sizeBytes {
|
|
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{"request body size must match artifact chunk size"}}
|
|
}
|
|
return dto.ArtifactChunkUploadRequest{
|
|
RunEndpointID: strings.TrimSpace(r.Header.Get(runEndpointHeader)),
|
|
SessionToken: strings.TrimSpace(r.Header.Get(runSessionTokenHeader)),
|
|
TransferID: strings.TrimSpace(r.Header.Get(artifactTransferIDHeader)),
|
|
ArtifactID: strings.TrimSpace(r.Header.Get(artifactIDHeader)),
|
|
ChunkIndex: chunkIndex,
|
|
Offset: offset,
|
|
SizeBytes: sizeBytes,
|
|
Checksum: strings.TrimSpace(r.Header.Get(artifactChunkHashHeader)),
|
|
Payload: payload,
|
|
}, nil
|
|
}
|
|
|
|
func parseRequiredIntHeader(r *http.Request, name string) (int, error) {
|
|
value := strings.TrimSpace(r.Header.Get(name))
|
|
if value == "" {
|
|
return 0, validator.ValidationError{Violations: []string{name + " is required"}}
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, validator.ValidationError{Violations: []string{name + " must be a number"}}
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func parseRequiredInt64Header(r *http.Request, name string) (int64, error) {
|
|
value := strings.TrimSpace(r.Header.Get(name))
|
|
if value == "" {
|
|
return 0, validator.ValidationError{Violations: []string{name + " is required"}}
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return 0, validator.ValidationError{Violations: []string{name + " must be a number"}}
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
// runArtifactStatus godoc
|
|
// @Summary Query run artifact upload status
|
|
// @Description Returns resumable chunk acknowledgement state for one artifact transfer.
|
|
// @Tags run-artifacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.ArtifactTransferStatusRequest true "Artifact transfer status request"
|
|
// @Success 200 {object} dto.ArtifactTransferStatusResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/artifacts/status [post]
|
|
func (h *coreHandlers) runArtifactStatus(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ArtifactTransferStatusRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.QueryArtifactTransferStatus(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ArtifactTransferStatusFromDomain(result))
|
|
}
|
|
|
|
// runArtifactComplete godoc
|
|
// @Summary Complete run artifact upload transfer
|
|
// @Description Marks an artifact available only after every chunk is present and final checksum validation passes.
|
|
// @Tags run-artifacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.ArtifactTransferCompleteRequest true "Artifact transfer complete request"
|
|
// @Success 200 {object} dto.ArtifactTransferCompleteResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/artifacts/complete [post]
|
|
func (h *coreHandlers) runArtifactComplete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.ArtifactTransferCompleteRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.CompleteArtifactTransfer(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ArtifactTransferCompleteFromDomain(result))
|
|
}
|
|
|
|
// runEndpoints godoc
|
|
// @Summary Create or list run endpoints
|
|
// @Description Creates or lists run endpoint metadata used by platform-mediated jobs.
|
|
// @Tags run
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.RunEndpointCreateRequest false "Run endpoint create request"
|
|
// @Success 200 {object} dto.RunEndpointListResponse
|
|
// @Success 201 {object} dto.RunEndpointResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/endpoints [get]
|
|
// @Router /api/v1/run/endpoints [post]
|
|
func (h *coreHandlers) runEndpoints(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
endpoints, err := h.core.ListRunEndpoints(domain.RunEndpointFilter{Status: domain.RunEndpointStatus(r.URL.Query().Get("status"))})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunEndpointListFromDomain(endpoints))
|
|
case http.MethodPost:
|
|
request, err := decodeJSON[dto.RunEndpointCreateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
endpoint, err := h.core.CreateRunEndpoint(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.RunEndpointFromDomain(endpoint))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, POST")
|
|
}
|
|
}
|
|
|
|
// runEndpointDetail godoc
|
|
// @Summary Get run endpoint
|
|
// @Description Returns one run endpoint by ID.
|
|
// @Tags run
|
|
// @Produce json
|
|
// @Param id path string true "Run endpoint ID"
|
|
// @Success 200 {object} dto.RunEndpointResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/run/endpoints/{id} [get]
|
|
func (h *coreHandlers) runEndpointDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
endpoint, err := h.core.GetRunEndpoint(r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunEndpointFromDomain(endpoint))
|
|
}
|
|
|
|
// jobs godoc
|
|
// @Summary Create or list jobs
|
|
// @Description Creates or lists platform job metadata.
|
|
// @Tags jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.JobCreateRequest false "Job create request"
|
|
// @Success 200 {object} dto.JobListResponse
|
|
// @Success 201 {object} dto.JobResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/jobs [get]
|
|
// @Router /api/v1/jobs [post]
|
|
func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
limit, err := optionalPositiveInt(r.URL.Query().Get("limit"))
|
|
if err != nil || limit < 0 {
|
|
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid job limit", nil)
|
|
return
|
|
}
|
|
filter := domain.JobFilter{
|
|
ServerInstanceID: r.URL.Query().Get("serverInstanceId"),
|
|
RunEndpointID: r.URL.Query().Get("runEndpointId"),
|
|
State: domain.JobState(r.URL.Query().Get("state")),
|
|
States: parseJobStates(r.URL.Query().Get("states")),
|
|
Limit: limit,
|
|
}
|
|
var jobs []domain.Job
|
|
if h.enforceAuthorization {
|
|
jobs, err = h.core.ListJobsForSession(bearerToken(r), filter)
|
|
} else {
|
|
jobs, err = h.core.ListJobs(filter)
|
|
}
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.JobListFromDomain(jobs))
|
|
case http.MethodPost:
|
|
request, err := decodeJSON[dto.JobCreateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
job, err := h.core.CreateJob(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.JobFromDomain(job))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, POST")
|
|
}
|
|
}
|
|
|
|
func parseJobStates(raw string) []domain.JobState {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil
|
|
}
|
|
states := []domain.JobState{}
|
|
for _, part := range strings.Split(raw, ",") {
|
|
state := domain.JobState(strings.TrimSpace(part))
|
|
if state != "" {
|
|
states = append(states, state)
|
|
}
|
|
}
|
|
return states
|
|
}
|
|
|
|
// jobCancel godoc
|
|
// @Summary Request job cancellation
|
|
// @Description Records a cancellation request for an accepted or running job; run observes it through the job cancel poll route.
|
|
// @Tags jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Param body body dto.RunJobCancelRequestBody true "Job cancel request"
|
|
// @Success 200 {object} dto.RunJobCancelRequestResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/jobs/{id}/cancel [post]
|
|
func (h *coreHandlers) jobCancel(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.RunJobCancelRequestBody](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
request.JobID = r.PathValue("id")
|
|
var result domain.RunJobCancelRequestResult
|
|
if h.enforceAuthorization {
|
|
result, err = h.core.RequestRunJobCancelForSession(bearerToken(r), request.ToDomain())
|
|
} else {
|
|
result, err = h.core.RequestRunJobCancel(request.ToDomain())
|
|
}
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.RunJobCancelRequestFromDomain(result))
|
|
}
|
|
|
|
// jobDetail godoc
|
|
// @Summary Get job
|
|
// @Description Returns one platform job by ID.
|
|
// @Tags jobs
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} dto.JobResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/jobs/{id} [get]
|
|
func (h *coreHandlers) jobDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
var job domain.Job
|
|
var err error
|
|
if h.enforceAuthorization {
|
|
job, err = h.core.GetJobForSession(bearerToken(r), r.PathValue("id"))
|
|
} else {
|
|
job, err = h.core.GetJob(r.PathValue("id"))
|
|
}
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.JobFromDomain(job))
|
|
}
|
|
|
|
// artifacts godoc
|
|
// @Summary Create or list artifact metadata
|
|
// @Description Creates or lists artifact metadata; run chunk upload and browser content download use dedicated artifact transfer routes.
|
|
// @Tags artifacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.ArtifactCreateRequest false "Artifact create request"
|
|
// @Success 200 {object} dto.ArtifactListResponse
|
|
// @Success 201 {object} dto.ArtifactResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/artifacts [get]
|
|
// @Router /api/v1/artifacts [post]
|
|
func (h *coreHandlers) artifacts(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
filter := domain.ArtifactFilter{
|
|
OwnerKind: domain.ArtifactOwnerKind(r.URL.Query().Get("ownerKind")),
|
|
OwnerID: r.URL.Query().Get("ownerId"),
|
|
State: domain.ArtifactState(r.URL.Query().Get("state")),
|
|
}
|
|
var artifacts []domain.Artifact
|
|
var err error
|
|
if h.enforceAuthorization {
|
|
artifacts, err = h.core.ListArtifactsForSession(bearerToken(r), filter)
|
|
} else {
|
|
artifacts, err = h.core.ListArtifacts(filter)
|
|
}
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ArtifactListFromDomain(artifacts))
|
|
case http.MethodPost:
|
|
request, err := decodeJSON[dto.ArtifactCreateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
artifact, err := h.core.CreateArtifact(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.ArtifactFromDomain(artifact))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, POST")
|
|
}
|
|
}
|
|
|
|
// artifactDetail godoc
|
|
// @Summary Get artifact metadata
|
|
// @Description Returns one artifact metadata record by ID.
|
|
// @Tags artifacts
|
|
// @Produce json
|
|
// @Param id path string true "Artifact ID"
|
|
// @Success 200 {object} dto.ArtifactResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/artifacts/{id} [get]
|
|
func (h *coreHandlers) artifactDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
artifact, err := h.core.GetArtifactForSession(bearerToken(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ArtifactFromDomain(artifact))
|
|
}
|
|
|
|
// artifactDownload godoc
|
|
// @Summary Open a browser-safe artifact download reference
|
|
// @Description Returns a platform-owned artifact download reference without exposing storage paths, direct run sockets, or credentials.
|
|
// @Tags artifacts
|
|
// @Produce json
|
|
// @Param id path string true "Artifact ID"
|
|
// @Success 200 {object} dto.ArtifactDownloadReferenceResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/artifacts/{id}/download [post]
|
|
func (h *coreHandlers) artifactDownload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
reference, err := h.core.OpenArtifactDownloadForSession(bearerToken(r), domain.ArtifactDownloadReferenceRequest{ArtifactID: r.PathValue("id")})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference))
|
|
}
|
|
|
|
// artifactContent godoc
|
|
// @Summary Read bounded artifact content
|
|
// @Description Streams a bounded artifact byte range through platform authorization with safe integrity headers.
|
|
// @Tags artifacts
|
|
// @Produce octet-stream
|
|
// @Param id path string true "Artifact ID"
|
|
// @Param offset query int false "Byte offset"
|
|
// @Param limit query int false "Maximum bytes"
|
|
// @Success 200 {file} binary
|
|
// @Success 206 {file} binary
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/artifacts/{id}/content [get]
|
|
func (h *coreHandlers) artifactContent(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
offset, limit, err := artifactRangeRequest(r)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
stream, err := h.core.OpenArtifactContentStreamForSession(bearerToken(r), domain.ArtifactContentRequest{ArtifactID: r.PathValue("id"), Offset: offset, Limit: limit})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
defer stream.Body.Close()
|
|
content := stream.Content
|
|
w.Header().Set("Content-Type", content.ContentType)
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(content.Filename, "\"", "")+"\"")
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Content-Length", strconv.FormatInt(content.SizeBytes, 10))
|
|
w.Header().Set("X-Artifact-Id", content.ArtifactID)
|
|
w.Header().Set("X-Artifact-Checksum", content.Checksum)
|
|
w.Header().Set("X-Artifact-Content-Checksum", content.ContentChecksum)
|
|
w.Header().Set("X-Artifact-Storage", content.StorageBehavior)
|
|
if content.Partial {
|
|
end := content.Offset + content.SizeBytes - 1
|
|
w.Header().Set("Content-Range", "bytes "+strconv.FormatInt(content.Offset, 10)+"-"+strconv.FormatInt(end, 10)+"/"+strconv.FormatInt(content.TotalSizeBytes, 10))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
} else {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
_, _ = io.Copy(w, stream.Body)
|
|
}
|
|
|
|
func artifactRangeRequest(r *http.Request) (int64, int, error) {
|
|
query := r.URL.Query()
|
|
offset, err := parseOptionalInt64(query.Get("offset"))
|
|
if err != nil {
|
|
return 0, 0, validator.ValidationError{Violations: []string{"offset must be a number"}}
|
|
}
|
|
limit64, err := parseOptionalInt64(query.Get("limit"))
|
|
if err != nil {
|
|
return 0, 0, validator.ValidationError{Violations: []string{"limit must be a number"}}
|
|
}
|
|
limit := int(limit64)
|
|
if rangeHeader := strings.TrimSpace(r.Header.Get("Range")); rangeHeader != "" {
|
|
rangeOffset, rangeLimit, ok := parseByteRange(rangeHeader)
|
|
if !ok {
|
|
return 0, 0, validator.ValidationError{Violations: []string{"range header is invalid"}}
|
|
}
|
|
offset = rangeOffset
|
|
limit = rangeLimit
|
|
}
|
|
return offset, limit, nil
|
|
}
|
|
|
|
func parseOptionalInt64(value string) (int64, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return 0, nil
|
|
}
|
|
return strconv.ParseInt(value, 10, 64)
|
|
}
|
|
|
|
func parseByteRange(header string) (int64, int, bool) {
|
|
if !strings.HasPrefix(header, "bytes=") {
|
|
return 0, 0, false
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(header, "bytes="), "-")
|
|
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
|
return 0, 0, false
|
|
}
|
|
start, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
end, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil || end < start {
|
|
return 0, 0, false
|
|
}
|
|
length := end - start + 1
|
|
if length > int64(validator.MaxArtifactDownloadBytes) {
|
|
return 0, 0, false
|
|
}
|
|
return start, int(length), true
|
|
}
|
|
|
|
// logStreams godoc
|
|
// @Summary Create or list log stream metadata
|
|
// @Description Creates or lists log stream metadata; durable ingest and cursor query use dedicated log routes while browser tail transport remains future work.
|
|
// @Tags logs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.LogStreamCreateRequest false "Log stream create request"
|
|
// @Success 200 {object} dto.LogStreamListResponse
|
|
// @Success 201 {object} dto.LogStreamResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/log-streams [get]
|
|
// @Router /api/v1/log-streams [post]
|
|
func (h *coreHandlers) logStreams(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
filter := domain.LogStreamFilter{
|
|
ServerInstanceID: r.URL.Query().Get("serverInstanceId"),
|
|
StreamKey: r.URL.Query().Get("streamKey"),
|
|
}
|
|
var streams []domain.LogStream
|
|
var err error
|
|
if h.enforceAuthorization {
|
|
streams, err = h.core.ListLogStreamsForSession(bearerToken(r), filter)
|
|
} else {
|
|
streams, err = h.core.ListLogStreams(filter)
|
|
}
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.LogStreamListFromDomain(streams))
|
|
case http.MethodPost:
|
|
request, err := decodeJSON[dto.LogStreamCreateRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
stream, err := h.core.CreateLogStream(request.ToDomain())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, dto.LogStreamFromDomain(stream))
|
|
default:
|
|
writeMethodNotAllowed(w, "GET, POST")
|
|
}
|
|
}
|
|
|
|
// logStreamQuery godoc
|
|
// @Summary Query log stream entries
|
|
// @Description Returns bounded stored log entries after a stream sequence cursor.
|
|
// @Tags logs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body dto.LogStreamCursorRequest true "Log stream cursor request"
|
|
// @Success 200 {object} dto.LogStreamCursorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/log-streams/query [post]
|
|
func (h *coreHandlers) logStreamQuery(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.LogStreamCursorRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
var result domain.LogStreamCursorResult
|
|
if h.enforceAuthorization {
|
|
result, err = h.core.QueryLogStreamForSession(bearerToken(r), request.ToDomain())
|
|
} else {
|
|
result, err = h.core.QueryLogStream(request.ToDomain())
|
|
}
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.LogStreamCursorFromDomain(result))
|
|
}
|
|
|
|
// logStreamDetail godoc
|
|
// @Summary Get log stream metadata
|
|
// @Description Returns one log stream metadata record by ID.
|
|
// @Tags logs
|
|
// @Produce json
|
|
// @Param id path string true "Log stream ID"
|
|
// @Success 200 {object} dto.LogStreamResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/log-streams/{id} [get]
|
|
func (h *coreHandlers) logStreamDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
var stream domain.LogStream
|
|
var err error
|
|
if h.enforceAuthorization {
|
|
stream, err = h.core.GetLogStreamForSession(bearerToken(r), r.PathValue("id"))
|
|
} else {
|
|
stream, err = h.core.GetLogStream(r.PathValue("id"))
|
|
}
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.LogStreamFromDomain(stream))
|
|
}
|