The SCUM 用户管理 list stayed empty because the typed scum_user and scum_vehicle tables had no producer: the run ingest endpoint is signed-run only, and the previously registered plugin templates asked for a Run database capability the endpoint never advertises. The plugin now declares bounded, read-only SQLite projections for players and vehicles (sql/scum-db-v57/*.sql with query schemas), and the platform dispatches those templates as durable remote.run.db.sqlite.query jobs on run heartbeat and page open, then projects the returned rows into the typed SCUM tables through the same shared ingest path used by signed run facts.
2887 lines
102 KiB
Go
2887 lines
102 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/ai/config-diffs", h.aiConfigDiffs)
|
|
mux.HandleFunc("/api/v1/ai/config-diffs/{id}/approve", h.aiConfigDiffApprove)
|
|
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))
|
|
}
|
|
|
|
// aiConfigDiffs godoc
|
|
// @Summary List reviewable AI config diffs
|
|
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
|
|
// @Tags plugin-operations
|
|
// @Produce json
|
|
// @Success 200 {object} dto.AIConfigDiffListResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/ai/config-diffs [get]
|
|
func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
items, err := h.core.ListAIConfigDiffsForSession(bearerToken(r), domain.AIConfigDiffFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), PluginID: r.URL.Query().Get("pluginId"), State: domain.AIConfigDiffState(r.URL.Query().Get("state"))})
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.AIConfigDiffListFromDomain(items))
|
|
}
|
|
|
|
// aiConfigDiffApprove godoc
|
|
// @Summary Approve one reviewable AI config diff
|
|
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
|
|
// @Tags plugin-operations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "AI config diff ID"
|
|
// @Param body body dto.AIConfigDiffApprovalRequest true "AI config diff approval"
|
|
// @Success 202 {object} dto.AIConfigDiffApprovalResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 403 {object} dto.ErrorResponse
|
|
// @Failure 405 {object} dto.ErrorResponse
|
|
// @Router /api/v1/ai/config-diffs/{id}/approve [post]
|
|
func (h *coreHandlers) aiConfigDiffApprove(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeMethodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
request, err := decodeJSON[dto.AIConfigDiffApprovalRequest](r)
|
|
if err != nil {
|
|
writeDecodeError(w, err)
|
|
return
|
|
}
|
|
result, err := h.core.ApproveAIConfigDiffForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, dto.AIConfigDiffApprovalFromDomain(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: "config-suggestion:" + request.ServerInstanceID,
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested})
|
|
}
|
|
|
|
// 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))
|
|
}
|