feat: 完整游戏运维功能
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/service"
|
||||
)
|
||||
|
||||
func (h *coreHandlers) requireAuthorizedAPI(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicAPIRequest(r) || runServiceRequest(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
user, err := h.core.GetCurrentUser(bearerToken(r))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if platformAdminRequest(r) && !apiPlatformAdmin(user) {
|
||||
writeServiceError(w, service.ErrForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func publicAPIRequest(r *http.Request) bool {
|
||||
path := r.URL.Path
|
||||
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" || path == "/api/v1/client-managers/register" || path == "/api/v1/client-managers/heartbeat" {
|
||||
return true
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
return path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") ||
|
||||
path == "/api/v1/plugin-marketplace/plugins" || strings.HasPrefix(path, "/api/v1/plugin-marketplace/plugins/")
|
||||
}
|
||||
|
||||
func runServiceRequest(r *http.Request) bool {
|
||||
return strings.HasPrefix(r.URL.Path, "/api/v1/run/control/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/jobs/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/logs/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/artifacts/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/metrics/")
|
||||
}
|
||||
|
||||
func platformAdminRequest(r *http.Request) bool {
|
||||
path := r.URL.Path
|
||||
if path == "/api/v1/users/current" || strings.HasPrefix(path, "/api/v1/users/current/") {
|
||||
return false
|
||||
}
|
||||
if path == "/api/v1/users" || strings.HasPrefix(path, "/api/v1/users/") ||
|
||||
path == "/api/v1/ai-providers" || strings.HasPrefix(path, "/api/v1/ai-providers/") ||
|
||||
path == "/api/v1/metrics/platform" ||
|
||||
path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") ||
|
||||
path == "/api/v1/audit-events" || strings.HasPrefix(path, "/api/v1/audit-events/") {
|
||||
return true
|
||||
}
|
||||
if r.Method != http.MethodGet && (path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") || strings.Contains(path, "/plugin-marketplace/plugins/")) {
|
||||
return true
|
||||
}
|
||||
return r.Method == http.MethodPost && (path == "/api/v1/jobs" || path == "/api/v1/artifacts" || path == "/api/v1/log-streams")
|
||||
}
|
||||
|
||||
func apiPlatformAdmin(user domain.User) bool {
|
||||
for _, role := range user.Roles {
|
||||
if role == "platform-admin" || role == "admin" || role == "platformadmin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/service"
|
||||
)
|
||||
|
||||
func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
core := service.NewCoreService(store)
|
||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||
t.Fatalf("seed admin: %v", err)
|
||||
}
|
||||
for _, user := range []domain.User{
|
||||
{ID: "user-owner", DisplayName: "Owner", Email: "owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
|
||||
{ID: "user-other", DisplayName: "Other", Email: "other@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
|
||||
} {
|
||||
if _, err := core.CreateUser(user); err != nil {
|
||||
t.Fatalf("create user %s: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
|
||||
t.Fatalf("create plugin: %v", err)
|
||||
}
|
||||
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
||||
t.Fatalf("create endpoint: %v", err)
|
||||
}
|
||||
if _, err := core.CreateServerInstance(domain.ServerInstance{
|
||||
ID: "server-owner", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Owner Server",
|
||||
OwnerUserID: "user-owner", State: domain.ServerInstanceStateReady, ConfigVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
if _, err := core.CreateJob(domain.Job{
|
||||
ID: "job-owner", ServerInstanceID: "server-owner", RunEndpointID: "run-local",
|
||||
Capability: "process.start", IdempotencyKey: "job-owner",
|
||||
}); err != nil {
|
||||
t.Fatalf("create job: %v", err)
|
||||
}
|
||||
router := NewAuthorizedRouterWithCore(core)
|
||||
|
||||
assertErrorResponse(t, performRaw(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", ""), http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
ownerAuth, err := core.LoginUser(domain.UserLogin{Account: "owner@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("login owner: %v", err)
|
||||
}
|
||||
otherAuth, err := core.LoginUser(domain.UserLogin{Account: "other@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("login other: %v", err)
|
||||
}
|
||||
ownerSession := ownerAuth.SessionID
|
||||
otherSession := otherAuth.SessionID
|
||||
|
||||
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-owner", ownerSession)
|
||||
if jobs.Count != 1 || jobs.Items[0].ID != "job-owner" {
|
||||
t.Fatalf("owner did not receive own jobs: %+v", jobs)
|
||||
}
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/jobs/job-owner/cancel", dto.RunJobCancelRequestBody{Reason: "cross-owner"}, otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
encodedJobs, err := json.Marshal(jobs)
|
||||
if err != nil {
|
||||
t.Fatalf("encode safe job projection: %v", err)
|
||||
}
|
||||
for _, forbidden := range []string{"leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket"} {
|
||||
if strings.Contains(string(encodedJobs), forbidden) {
|
||||
t.Fatalf("job projection exposed forbidden field %q: %s", forbidden, encodedJobs)
|
||||
}
|
||||
}
|
||||
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/game-plugins", validGamePluginRequest(), ownerSession), http.StatusForbidden, errorCodeForbidden)
|
||||
assertErrorResponse(t, performJSON(t, router, http.MethodPost, "/api/v1/plugin-marketplace/plugins/server.scum/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionDisable}), http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
}
|
||||
|
||||
func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
core := service.NewCoreService(store)
|
||||
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
||||
t.Fatalf("create endpoint: %v", err)
|
||||
}
|
||||
token := "run-session-secret"
|
||||
stamp := time.Now().UTC()
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
if err := store.RunControlSessions().Create(domain.RunControlSession{
|
||||
RunEndpointID: "run-local", SessionTokenHash: hex.EncodeToString(hash[:]), Status: domain.AuthSessionStatusActive,
|
||||
Generation: 1, CapabilityFingerprint: "cap-v1", HeartbeatIntervalSeconds: 15,
|
||||
CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create Run session: %v", err)
|
||||
}
|
||||
router := NewTestRouterWithCore(core)
|
||||
request := dto.RunControlHeartbeatRequest{
|
||||
RunEndpointID: "run-local", SessionToken: token, Version: "0.1.1", Status: domain.RunEndpointStatusOnline,
|
||||
CapabilityFingerprint: "cap-v1", Capacity: dto.RunCapacityResponse{MaxJobs: 4},
|
||||
}
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal heartbeat: %v", err)
|
||||
}
|
||||
unsigned := performRequest(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", bytes.NewReader(body))
|
||||
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
|
||||
signed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
|
||||
assertStatus(t, signed, http.StatusOK)
|
||||
replayed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
|
||||
assertErrorResponse(t, replayed, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
|
||||
if _, err := core.CreateJob(domain.Job{ID: "job-signed", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed"}); err != nil {
|
||||
t.Fatalf("create signed job: %v", err)
|
||||
}
|
||||
claimBody, err := json.Marshal(dto.RunJobClaimRequest{
|
||||
RunEndpointID: "run-local", SessionToken: token, Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal signed claim: %v", err)
|
||||
}
|
||||
unsignedClaim := performRequest(t, router, http.MethodPost, "/api/v1/run/jobs/claim", bytes.NewReader(claimBody))
|
||||
assertErrorResponse(t, unsignedClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
signedClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", claimBody, token, "nonce-api-job-1", stamp)
|
||||
assertStatus(t, signedClaim, http.StatusOK)
|
||||
|
||||
staleClaimBody, err := json.Marshal(dto.RunJobClaimRequest{
|
||||
RunEndpointID: "run-local", SessionToken: "stale-session", Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal stale claim: %v", err)
|
||||
}
|
||||
staleClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", staleClaimBody, "stale-session", "nonce-api-job-2", stamp)
|
||||
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
|
||||
privateUpdateBodies := map[string]any{
|
||||
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
|
||||
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
||||
}
|
||||
nonce := 10
|
||||
for path, request := range privateUpdateBodies {
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal private Run request for %s: %v", path, err)
|
||||
}
|
||||
unsigned := performRequest(t, router, http.MethodPost, path, bytes.NewReader(body))
|
||||
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
nonce++
|
||||
signed := signedRunRequest(t, router, path, body, token, fmt.Sprintf("nonce-api-private-%d", nonce), stamp)
|
||||
if signed.Code == http.StatusUnauthorized {
|
||||
t.Fatalf("valid signature was rejected for %s: %s", path, signed.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signedRunRequest(t *testing.T, router http.Handler, path string, body []byte, token string, nonce string, stamp time.Time) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
timestamp := strconv.FormatInt(stamp.Unix(), 10)
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(token))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Run-Endpoint", "run-local")
|
||||
req.Header.Set("X-Run-Timestamp", timestamp)
|
||||
req.Header.Set("X-Run-Nonce", nonce)
|
||||
req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestSessionRotationRouteRevokesPreviousBearer(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
current := createAdminSession(t, router)
|
||||
rotated := postOKJSONWithAuth[dto.AuthSessionResponse](t, router, "/api/v1/auth/rotate", map[string]string{}, current)
|
||||
if rotated.SessionID == "" || rotated.SessionID == current || rotated.ExpiresAt.IsZero() {
|
||||
t.Fatalf("unexpected rotated session: %+v", rotated)
|
||||
}
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", current), http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
currentUser := getJSONWithAuth[dto.CurrentUserResponse](t, router, "/api/v1/users/current", rotated.SessionID)
|
||||
if currentUser.ID != "user-admin" {
|
||||
t.Fatalf("rotated session resolved unexpected user: %+v", currentUser)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// serverClientManagerLifecycles godoc
|
||||
// @Summary List Client Manager lifecycle installations
|
||||
// @Description Returns safe durable lifecycle, health, real job progress, and action availability for the authorized server without component secrets or machine details.
|
||||
// @Tags client-managers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Success 200 {object} dto.ClientManagerInstallationListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers [get]
|
||||
func (h *coreHandlers) serverClientManagerLifecycles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
views, err := h.core.ListClientManagerLifecyclesForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewsFromDomain(views))
|
||||
}
|
||||
|
||||
// serverClientManagerLifecycleDetail godoc
|
||||
// @Summary Get one Client Manager lifecycle installation
|
||||
// @Tags client-managers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param profileKey path string true "Client Manager profile key"
|
||||
// @Success 200 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/{profileKey} [get]
|
||||
func (h *coreHandlers) serverClientManagerLifecycleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
view, err := h.core.GetClientManagerLifecycleForSession(bearerToken(r), r.PathValue("id"), r.PathValue("profileKey"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerDeploy godoc
|
||||
// @Summary Deploy an available Client Manager distribution
|
||||
// @Description Queues a typed Run deployment after server, endpoint, artifact, target, revision, and key-generation authorization.
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerDeployRequest true "Deployment request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/deploy [post]
|
||||
func (h *coreHandlers) serverClientManagerDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerDeployRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.DeployClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerControl godoc
|
||||
// @Summary Control a deployed Client Manager
|
||||
// @Description Queues a declared typed start, stop, restart, status, or rollback operation.
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerControlRequest true "Control request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/control [post]
|
||||
func (h *coreHandlers) serverClientManagerControl(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerControlRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.ControlClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerUpdateLifecycle godoc
|
||||
// @Summary Update a Client Manager through staged activation
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerUpdateRequest true "Approved staged update request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/update [post]
|
||||
func (h *coreHandlers) serverClientManagerUpdateLifecycle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerUpdateRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.UpdateClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerRetry godoc
|
||||
// @Summary Retry a failed Client Manager lifecycle intent
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerRetryRequest true "Retry request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/retry [post]
|
||||
func (h *coreHandlers) serverClientManagerRetry(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerRetryRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.RetryClientManagerLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerRevokeSession godoc
|
||||
// @Summary Revoke the active Client Manager component session
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerRevokeSessionRequest true "Session revoke request"
|
||||
// @Success 200 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/revoke-session [post]
|
||||
func (h *coreHandlers) serverClientManagerRevokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerRevokeSessionRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.RevokeClientManagerSessionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerUninstall godoc
|
||||
// @Summary Safely uninstall a Client Manager
|
||||
// @Description Stops the supervised process and removes only the controlled installation workspace while retaining audit and distribution history.
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerUninstallRequest true "Confirmed uninstall request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/uninstall [post]
|
||||
func (h *coreHandlers) serverClientManagerUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerUninstallRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.UninstallClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// runClientManagerLifecycleInput godoc
|
||||
// @Summary Get fenced Client Manager lifecycle input
|
||||
// @Description Returns a safe typed lifecycle contract only to the authenticated Run endpoint holding the active job lease.
|
||||
// @Tags run-job-channel
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.ClientManagerLifecycleInputRequest true "Fenced lifecycle input request"
|
||||
// @Success 200 {object} dto.ClientManagerLifecycleInputResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/run/jobs/client-manager-input [post]
|
||||
func (h *coreHandlers) runClientManagerLifecycleInput(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerLifecycleInputRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
input, err := h.core.GetClientManagerLifecycleInput(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleInputFromDomain(input))
|
||||
}
|
||||
|
||||
// runClientManagerLifecycleChunk godoc
|
||||
// @Summary Read one fenced Client Manager artifact chunk
|
||||
// @Description Streams a checksummed artifact chunk only to the active typed lifecycle job lease.
|
||||
// @Tags run-job-channel
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.RunUpdateChunkRequest true "Fenced chunk request"
|
||||
// @Success 200 {object} dto.RunUpdateChunkResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/run/jobs/client-manager-chunk [post]
|
||||
func (h *coreHandlers) runClientManagerLifecycleChunk(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
|
||||
}
|
||||
chunk, err := h.core.ReadClientManagerLifecycleChunk(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(chunk))
|
||||
}
|
||||
|
||||
// clientManagerRegister godoc
|
||||
// @Summary Register an installed Client Manager component
|
||||
// @Description Verifies a current component-key HMAC, nonce, deployment fence, target, revision, and capabilities before issuing an isolated expiring component session.
|
||||
// @Tags client-manager-component
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.ClientManagerRegisterRequest true "Signed component registration"
|
||||
// @Success 200 {object} dto.ClientManagerRegisterResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/client-managers/register [post]
|
||||
func (h *coreHandlers) clientManagerRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerRegisterRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.RegisterClientManager(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerRegisterFromDomain(result))
|
||||
}
|
||||
|
||||
// clientManagerHeartbeat godoc
|
||||
// @Summary Accept a Client Manager component heartbeat
|
||||
// @Description Accepts monotonic health reports using the isolated component session; Run control credentials are not valid here.
|
||||
// @Tags client-manager-component
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.ClientManagerHeartbeatRequest true "Component heartbeat"
|
||||
// @Success 200 {object} dto.ClientManagerHeartbeatResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/client-managers/heartbeat [post]
|
||||
func (h *coreHandlers) clientManagerHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerHeartbeatRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.AcceptClientManagerHeartbeat(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerHeartbeatFromDomain(result))
|
||||
}
|
||||
@@ -78,6 +78,7 @@ func TestRunJobChannelAPIWorkflow(t *testing.T) {
|
||||
SessionToken: hello.SessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
})
|
||||
assertStatus(t, pollRecorder, http.StatusOK)
|
||||
poll := decodeBody[dto.RunJobCancelPollResponse](t, pollRecorder)
|
||||
@@ -104,11 +105,11 @@ func TestRunJobChannelAPIWorkflow(t *testing.T) {
|
||||
reconcileRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/reconcile", dto.RunJobReconcileRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
ActiveJobIDs: []string{"local-only"},
|
||||
ActiveJobs: []dto.RunJobReconcileEntry{{JobID: "local-only", LeaseToken: "local-lease", Attempt: 1}},
|
||||
})
|
||||
assertStatus(t, reconcileRecorder, http.StatusOK)
|
||||
reconcile := decodeBody[dto.RunJobReconcileResponse](t, reconcileRecorder)
|
||||
if len(reconcile.ActiveJobs) != 0 || len(reconcile.UnknownJobIDs) != 1 || reconcile.UnknownJobIDs[0] != "local-only" {
|
||||
if len(reconcile.ConfirmedJobs) != 0 || len(reconcile.DiscardJobIDs) != 1 || reconcile.DiscardJobIDs[0] != "local-only" {
|
||||
t.Fatalf("expected no active platform jobs and one unknown local job, got %+v", reconcile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// runMetricBatchIngest godoc
|
||||
// @Summary Ingest bounded Run metric samples
|
||||
// @Description Persists a signed bounded metric batch for server instances owned by the Run endpoint.
|
||||
// @Tags run-metrics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.MetricBatchIngestRequest true "Metric batch"
|
||||
// @Success 200 {object} dto.MetricBatchIngestResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/run/metrics/batches [post]
|
||||
func (h *coreHandlers) runMetricBatchIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.MetricBatchIngestRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.IngestMetricBatch(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.MetricBatchIngestFromDomain(result))
|
||||
}
|
||||
|
||||
// metricHistory godoc
|
||||
// @Summary Query persisted server metric samples
|
||||
// @Description Returns an owner-authorized bounded metric history without machine credentials or fencing state.
|
||||
// @Tags metrics
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.MetricSampleListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/metrics/server-instances/history [get]
|
||||
func (h *coreHandlers) metricHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.core.ListMetricSamplesForSession(bearerToken(r), domain.MetricSampleFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), After: parseQueryTime(r.URL.Query().Get("after")), Before: parseQueryTime(r.URL.Query().Get("before")), Limit: limit})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.MetricSampleListFromDomain(items))
|
||||
}
|
||||
|
||||
// backups godoc
|
||||
// @Summary Create or list durable backup records
|
||||
// @Description Creates or returns owner-authorized backup metadata and recovery state.
|
||||
// @Tags backups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.BackupListResponse
|
||||
// @Success 201 {object} dto.BackupResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/backups [get]
|
||||
// @Router /api/v1/backups [post]
|
||||
func (h *coreHandlers) backups(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
items, err := h.core.ListBackupsForSession(bearerToken(r), domain.BackupFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), State: domain.BackupState(r.URL.Query().Get("state"))})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.BackupListFromDomain(items))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.BackupCreateRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
record, err := h.core.CreateBackupForSession(bearerToken(r), request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dto.BackupFromDomain(record))
|
||||
default:
|
||||
writeMethodNotAllowed(w, "GET, POST")
|
||||
}
|
||||
}
|
||||
|
||||
// backupDetail godoc
|
||||
// @Summary Get one durable backup record
|
||||
// @Tags backups
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.BackupResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/backups/{id} [get]
|
||||
func (h *coreHandlers) backupDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
record, err := h.core.GetBackupForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.BackupFromDomain(record))
|
||||
}
|
||||
|
||||
func parseQueryTime(value string) time.Time {
|
||||
parsed, _ := time.Parse(time.RFC3339Nano, value)
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// remoteAdapters godoc
|
||||
// @Summary List or request scoped remote adapters
|
||||
// @Description Lists declared safe adapters or queues an owner-authorized fenced adapter job.
|
||||
// @Tags remote-adapters
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.RemoteAdapterDeclarationListResponse
|
||||
// @Success 202 {object} dto.RemoteAdapterResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/remote-adapters [get]
|
||||
// @Router /api/v1/server-instances/{id}/remote-adapters [post]
|
||||
func (h *coreHandlers) remoteAdapters(w http.ResponseWriter, r *http.Request) {
|
||||
serverInstanceID := r.PathValue("id")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
declarations, err := h.core.ListRemoteAdapterDeclarationsForSession(bearerToken(r), serverInstanceID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.RemoteAdapterDeclarationsFromDomain(declarations))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.RemoteAdapterRequestBody](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.RequestRemoteAdapterForSession(bearerToken(r), request.ToDomain(serverInstanceID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.RemoteAdapterFromDomain(result))
|
||||
default:
|
||||
writeMethodNotAllowed(w, "GET, POST")
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,19 @@ import (
|
||||
)
|
||||
|
||||
type coreHandlers struct {
|
||||
core service.Core
|
||||
core service.Core
|
||||
enforceAuthorization bool
|
||||
}
|
||||
|
||||
func newCoreHandlers(core service.Core) *coreHandlers {
|
||||
return &coreHandlers{core: core}
|
||||
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)
|
||||
@@ -45,11 +47,18 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
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/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}/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}/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)
|
||||
@@ -57,8 +66,17 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/generate", h.serverClientManagerGenerate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/download", h.serverClientManagerDownload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/key/reset", h.serverClientManagerKeyReset)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers", h.serverClientManagerLifecycles)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/{profileKey}", h.serverClientManagerLifecycleDetail)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/deploy", h.serverClientManagerDeploy)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/control", h.serverClientManagerControl)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/update", h.serverClientManagerUpdateLifecycle)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/retry", h.serverClientManagerRetry)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/revoke-session", h.serverClientManagerRevokeSession)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/uninstall", h.serverClientManagerUninstall)
|
||||
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/live", h.serverLiveLogs)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
|
||||
@@ -69,19 +87,25 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
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.runControlHeartbeat)
|
||||
mux.HandleFunc("/api/v1/run/jobs/claim", h.runJobClaim)
|
||||
mux.HandleFunc("/api/v1/run/jobs/ack", h.runJobAck)
|
||||
mux.HandleFunc("/api/v1/run/jobs/progress", h.runJobProgress)
|
||||
mux.HandleFunc("/api/v1/run/jobs/result", h.runJobResult)
|
||||
mux.HandleFunc("/api/v1/run/jobs/build-input", h.runJobBuildInput)
|
||||
mux.HandleFunc("/api/v1/run/jobs/cancel", h.runJobCancelPoll)
|
||||
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.runJobReconcile)
|
||||
mux.HandleFunc("/api/v1/run/logs/batches", h.runLogBatchIngest)
|
||||
mux.HandleFunc("/api/v1/run/artifacts/open", h.runArtifactOpen)
|
||||
mux.HandleFunc("/api/v1/run/artifacts/chunks", h.runArtifactChunkUpload)
|
||||
mux.HandleFunc("/api/v1/run/artifacts/status", h.runArtifactStatus)
|
||||
mux.HandleFunc("/api/v1/run/artifacts/complete", h.runArtifactComplete)
|
||||
mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat))
|
||||
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/update-input", h.requireRunSignature(h.runJobUpdateInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
|
||||
mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk))
|
||||
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/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)
|
||||
@@ -97,6 +121,8 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail)
|
||||
mux.HandleFunc("/api/v1/audit-events", h.auditEvents)
|
||||
mux.HandleFunc("/api/v1/audit-events/{id}", h.auditEventDetail)
|
||||
mux.HandleFunc("/api/v1/client-managers/register", h.clientManagerRegister)
|
||||
mux.HandleFunc("/api/v1/client-managers/heartbeat", h.clientManagerHeartbeat)
|
||||
}
|
||||
|
||||
// authRegister godoc
|
||||
@@ -126,7 +152,7 @@ func (h *coreHandlers) authRegister(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.AuthSessionFromDomain(session))
|
||||
h.writeAuthSession(w, r, session)
|
||||
}
|
||||
|
||||
// authLogin godoc
|
||||
@@ -157,7 +183,7 @@ func (h *coreHandlers) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.AuthSessionFromDomain(session))
|
||||
h.writeAuthSession(w, r, session)
|
||||
}
|
||||
|
||||
// authLogout godoc
|
||||
@@ -178,9 +204,32 @@ func (h *coreHandlers) authLogout(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
@@ -274,10 +323,14 @@ func (h *coreHandlers) currentUserTheme(w http.ResponseWriter, r *http.Request)
|
||||
func bearerToken(r *http.Request) string {
|
||||
const prefix = "Bearer "
|
||||
header := r.Header.Get("Authorization")
|
||||
if len(header) < len(prefix) || header[:len(prefix)] != prefix {
|
||||
if len(header) >= len(prefix) && header[:len(prefix)] == prefix {
|
||||
return header[len(prefix):]
|
||||
}
|
||||
cookie, err := r.Cookie(platformSessionCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return header[len(prefix):]
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
// pluginBridgeAuthorize godoc
|
||||
@@ -302,7 +355,12 @@ func (h *coreHandlers) pluginBridgeAuthorize(w http.ResponseWriter, r *http.Requ
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.AuthorizePluginBridgeAction(request.ToDomain())
|
||||
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
|
||||
@@ -530,7 +588,11 @@ func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request)
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
provider, err := h.core.UpdateAIProvider(r.PathValue("id"), request.ToDomain(r.PathValue("id"), existing.Status))
|
||||
update := request.ToDomain(r.PathValue("id"), existing.Status)
|
||||
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
|
||||
@@ -1418,6 +1480,93 @@ func (h *coreHandlers) runJobBuildInput(w http.ResponseWriter, r *http.Request)
|
||||
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))
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1704,11 +1853,18 @@ func (h *coreHandlers) runEndpointDetail(w http.ResponseWriter, r *http.Request)
|
||||
func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jobs, err := h.core.ListJobs(domain.JobFilter{
|
||||
filter := domain.JobFilter{
|
||||
ServerInstanceID: r.URL.Query().Get("serverInstanceId"),
|
||||
RunEndpointID: r.URL.Query().Get("runEndpointId"),
|
||||
State: domain.JobState(r.URL.Query().Get("state")),
|
||||
})
|
||||
}
|
||||
var jobs []domain.Job
|
||||
var err error
|
||||
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
|
||||
@@ -1755,7 +1911,12 @@ func (h *coreHandlers) jobCancel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
request.JobID = r.PathValue("id")
|
||||
result, err := h.core.RequestRunJobCancel(request.ToDomain())
|
||||
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
|
||||
@@ -1778,7 +1939,13 @@ func (h *coreHandlers) jobDetail(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
job, err := h.core.GetJob(r.PathValue("id"))
|
||||
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
|
||||
@@ -1802,11 +1969,18 @@ func (h *coreHandlers) jobDetail(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *coreHandlers) artifacts(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
artifacts, err := h.core.ListArtifacts(domain.ArtifactFilter{
|
||||
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
|
||||
@@ -1995,10 +2169,17 @@ func parseByteRange(header string) (int64, int, bool) {
|
||||
func (h *coreHandlers) logStreams(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{
|
||||
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
|
||||
@@ -2043,7 +2224,12 @@ func (h *coreHandlers) logStreamQuery(w http.ResponseWriter, r *http.Request) {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.QueryLogStream(request.ToDomain())
|
||||
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
|
||||
@@ -2066,7 +2252,13 @@ func (h *coreHandlers) logStreamDetail(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
stream, err := h.core.GetLogStream(r.PathValue("id"))
|
||||
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
|
||||
|
||||
@@ -36,8 +36,8 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
|
||||
assertListCount(t, users.Count, 2)
|
||||
|
||||
providerResponse := createAIProviderFixture(t, router, adminSession)
|
||||
if providerResponse.APIKeyRef != "secret://providers/openai" {
|
||||
t.Fatalf("expected AI provider key reference, got %+v", providerResponse)
|
||||
if !providerResponse.APIKeyConfigured {
|
||||
t.Fatalf("expected AI provider key presence, got %+v", providerResponse)
|
||||
}
|
||||
getJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai", adminSession)
|
||||
providers := getJSONWithAuth[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active", adminSession)
|
||||
@@ -222,6 +222,7 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
Name: "Config API Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, ownerSession)
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
|
||||
config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession)
|
||||
proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1)
|
||||
|
||||
@@ -317,6 +318,19 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
}
|
||||
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
|
||||
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
|
||||
lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession)
|
||||
if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil {
|
||||
t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList)
|
||||
}
|
||||
deployRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/deploy", dto.ClientManagerDeployRequest{ProfileKey: "scum-client-manager", DistributionID: clientLinux.ID, IdempotencyKey: "api-client-manager-deploy"}, adminSession)
|
||||
assertErrorResponse(t, deployRecorder, http.StatusBadRequest, errorCodeValidation)
|
||||
detail := getJSONWithAuth[dto.ClientManagerInstallationResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/scum-client-manager", adminSession)
|
||||
if detail.CurrentJobID != "" || detail.KeyGeneration <= 0 {
|
||||
t.Fatalf("unexpected client-manager lifecycle detail: %+v", detail)
|
||||
}
|
||||
unauthorizedLifecycle := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/client-managers", "", "")
|
||||
assertErrorResponse(t, unauthorizedLifecycle, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
|
||||
dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession)
|
||||
assertStatus(t, dependencyCheckRecorder, http.StatusAccepted)
|
||||
@@ -324,7 +338,11 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
if dependencyCheck.Capability != domain.JobCapabilityDependenciesCheck || dependencyCheck.TargetKey != "dependencies/java-runtime" {
|
||||
t.Fatalf("unexpected dependency check job: %+v", dependencyCheck)
|
||||
}
|
||||
dependencyInstallRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "java-install", IdempotencyKey: "api-dependency-install"}, adminSession)
|
||||
dependencyCatalog := getJSONWithAuth[dto.DependencyCatalogResponse](t, router, "/api/v1/server-instances/"+serverID+"/dependencies", adminSession)
|
||||
if len(dependencyCatalog.Plans) != 1 || dependencyCatalog.Plans[0].Digest == "" {
|
||||
t.Fatalf("expected reviewable dependency plan, got %+v", dependencyCatalog)
|
||||
}
|
||||
dependencyInstallRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "java-install", PlanDigest: dependencyCatalog.Plans[0].Digest, IdempotencyKey: "api-dependency-install"}, adminSession)
|
||||
assertStatus(t, dependencyInstallRecorder, http.StatusAccepted)
|
||||
dependencyInstall := decodeBody[dto.JobResponse](t, dependencyInstallRecorder)
|
||||
if dependencyInstall.Capability != domain.JobCapabilityDependenciesInstall || dependencyInstall.TargetKey != "dependencies/install/java-install" {
|
||||
@@ -466,22 +484,39 @@ func TestAuthSessionAPI(t *testing.T) {
|
||||
|
||||
func TestDefaultRouterSeedsLocalPlatformAdmin(t *testing.T) {
|
||||
router, err := NewRouterFromConfig(config.Config{
|
||||
StorageBackend: "file",
|
||||
MetadataPath: filepath.Join(t.TempDir(), "metadata.json"),
|
||||
LogDir: filepath.Join(t.TempDir(), "logs"),
|
||||
StorageBackend: "file",
|
||||
MetadataPath: filepath.Join(t.TempDir(), "metadata.json"),
|
||||
LogDir: filepath.Join(t.TempDir(), "logs"),
|
||||
BootstrapAdminEmail: "operator.local@example.test",
|
||||
BootstrapAdminPassword: "operator-local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create default router: %v", err)
|
||||
}
|
||||
login := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{
|
||||
loginRecorder := performJSON(t, router, http.MethodPost, "/api/v1/auth/login", dto.LoginRequest{
|
||||
Account: "operator.local@example.test",
|
||||
Password: "operator-local",
|
||||
})
|
||||
if login.SessionID == "" || login.Status != "authenticated" || login.User.ID != "user-admin" {
|
||||
assertStatus(t, loginRecorder, http.StatusOK)
|
||||
login := decodeBody[dto.AuthSessionResponse](t, loginRecorder)
|
||||
if login.SessionID != "" || login.Status != "authenticated" || login.User.ID != "user-admin" {
|
||||
t.Fatalf("unexpected default operator login response: %+v", login)
|
||||
}
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range loginRecorder.Result().Cookies() {
|
||||
if cookie.Name == platformSessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("expected strict HttpOnly session cookie, got %+v", sessionCookie)
|
||||
}
|
||||
|
||||
current := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", login.SessionID)
|
||||
currentRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users/current", nil)
|
||||
currentRequest.AddCookie(sessionCookie)
|
||||
current := httptest.NewRecorder()
|
||||
router.ServeHTTP(current, currentRequest)
|
||||
assertStatus(t, current, http.StatusOK)
|
||||
currentUser := decodeBody[dto.CurrentUserResponse](t, current)
|
||||
if currentUser.ID != "user-admin" || len(currentUser.Roles) == 0 || currentUser.Roles[0] != "platform-admin" {
|
||||
@@ -601,6 +636,7 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM Create",
|
||||
IdempotencyKey: "idem-create",
|
||||
ProfileKey: "local",
|
||||
}, adminSession)
|
||||
if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall {
|
||||
t.Fatalf("expected create workflow response, got %+v", created)
|
||||
@@ -613,6 +649,7 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
|
||||
Name: "SCUM Ready",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
}, adminSession)
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/server-ready/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
||||
started := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{
|
||||
ExpectedConfigVersion: ready.ConfigVersion,
|
||||
IdempotencyKey: "idem-start",
|
||||
@@ -628,6 +665,7 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
|
||||
Name: "SCUM Running",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, adminSession)
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/server-running/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
||||
stopped := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-running/stop", dto.ServerLifecycleCommandRequest{
|
||||
ExpectedConfigVersion: running.ConfigVersion,
|
||||
IdempotencyKey: "idem-stop",
|
||||
@@ -795,8 +833,8 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
||||
if _, exists := body["rawApiKey"]; exists {
|
||||
t.Fatalf("AI provider response must not expose rawApiKey: %+v", body)
|
||||
}
|
||||
if body["apiKeyRef"] != "secret://providers/openai" {
|
||||
t.Fatalf("expected apiKeyRef only, got %+v", body)
|
||||
if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true {
|
||||
t.Fatalf("expected API key presence only, got %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,7 +847,7 @@ func TestAIProviderManagementAPI(t *testing.T) {
|
||||
updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
|
||||
assertStatus(t, updatedRecorder, http.StatusOK)
|
||||
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
|
||||
if updated.Name != "OpenAI Relay" || updated.APIKeyRef != "vault://providers/openai" || updated.Status != domain.AIProviderStatusActive {
|
||||
if updated.Name != "OpenAI Relay" || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
|
||||
t.Fatalf("unexpected updated provider: %+v", updated)
|
||||
}
|
||||
|
||||
@@ -1133,6 +1171,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
|
||||
Name: "Bridge API Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, ownerSession)
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
|
||||
lifecycleInstance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-bridge-lifecycle-api",
|
||||
PluginID: "game.example",
|
||||
@@ -1140,6 +1179,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
|
||||
Name: "Bridge Lifecycle API Server",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
}, ownerSession)
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+lifecycleInstance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
|
||||
stream := postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||
ID: "log-bridge-api",
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -1276,6 +1316,82 @@ func TestGamePluginManifestRegistryAPIRejectsUnsafeManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
registration := validGamePluginManifestRegistrationRequest()
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "remote.run.rcon.command")
|
||||
registration.Manifest.RuntimeProfiles = dto.GamePluginRuntimeProfilesBody{
|
||||
Discovery: []dto.RuntimeDiscoveryProbeBody{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true}},
|
||||
LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}, TransportKeys: []string{"rcon"}}},
|
||||
TransportProfiles: []dto.RuntimeTransportProfileBody{{Key: "rcon", Kind: "rcon", TargetKey: "rcon.password", Capabilities: []string{"remote.run.rcon.command"}}},
|
||||
}
|
||||
registered := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
||||
if len(registered.RuntimeProfiles.LifecycleProfiles) != 1 || registered.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
|
||||
t.Fatalf("runtime profiles were not projected: %+v", registered.RuntimeProfiles)
|
||||
}
|
||||
endpoint := validRunEndpointRequest()
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, "remote.run.rcon.command")
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
|
||||
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "runtime-binding-api", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Binding API", State: domain.ServerInstanceStateReady}, adminSession)
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "runtime-binding-other", DisplayName: "Runtime Binding Other", Email: "runtime-binding-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession)
|
||||
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "runtime-binding-other@example.test", Password: "secret-password"}).SessionID
|
||||
assertErrorResponse(t, performRequest(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/runtime-binding", nil), http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/runtime-binding", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local"}, otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
|
||||
unconfigured := getJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", adminSession)
|
||||
if unconfigured.Configured || unconfigured.Reason != "runtime profile is not configured" {
|
||||
t.Fatalf("unexpected unconfigured projection: %+v", unconfigured)
|
||||
}
|
||||
missingStart := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+server.ID+"/start", dto.ServerLifecycleCommandRequest{ExpectedConfigVersion: server.ConfigVersion, IdempotencyKey: "api-start-missing-binding"}, adminSession)
|
||||
assertErrorResponse(t, missingStart, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
incompleteRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
|
||||
assertStatus(t, incompleteRecorder, http.StatusOK)
|
||||
incompleteBody := incompleteRecorder.Body.String()
|
||||
incomplete := decodeBody[dto.RuntimeBindingResponse](t, incompleteRecorder)
|
||||
if incomplete.Status != domain.RuntimeBindingStatusIncomplete || len(incomplete.MissingKeys) != 1 || incomplete.MissingKeys[0] != "rcon.password" {
|
||||
t.Fatalf("unexpected incomplete projection: %+v", incomplete)
|
||||
}
|
||||
if strings.Contains(incompleteBody, "runtime.server-root") {
|
||||
t.Fatalf("binding response exposed stored logical ref: %s", incompleteBody)
|
||||
}
|
||||
|
||||
completeRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"rcon.password": "secret://runtime-binding-api/rcon"}}, adminSession)
|
||||
assertStatus(t, completeRecorder, http.StatusOK)
|
||||
completeBody := completeRecorder.Body.String()
|
||||
complete := decodeBody[dto.RuntimeBindingResponse](t, completeRecorder)
|
||||
secretFlag := false
|
||||
for _, key := range complete.Keys {
|
||||
if key.Key == "rcon.password" {
|
||||
secretFlag = key.Configured && key.Secret
|
||||
}
|
||||
}
|
||||
if complete.Status != domain.RuntimeBindingStatusComplete || !secretFlag {
|
||||
t.Fatalf("unexpected complete projection: %+v", complete)
|
||||
}
|
||||
for _, forbidden := range []string{"secret://runtime-binding-api/rcon", "runtime.server-root", "/srv/game", "unix://", "tcp://", "password="} {
|
||||
if strings.Contains(completeBody, forbidden) {
|
||||
t.Fatalf("runtime binding response exposed %q: %s", forbidden, completeBody)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"server-root": "/srv/game"}}, adminSession)
|
||||
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
|
||||
undeclared := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"host.socket": "runtime.socket"}}, adminSession)
|
||||
assertErrorResponse(t, undeclared, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-complete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Complete", IdempotencyKey: "runtime-create-complete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession)
|
||||
if created.Job.TargetKey != "local" {
|
||||
t.Fatalf("create workflow did not dispatch selected profile: %+v", created.Job)
|
||||
}
|
||||
incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-incomplete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Incomplete", IdempotencyKey: "runtime-create-incomplete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
|
||||
assertErrorResponse(t, incompleteCreate, http.StatusBadRequest, errorCodeValidation)
|
||||
missingServer := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/runtime-create-incomplete", "", adminSession)
|
||||
assertErrorResponse(t, missingServer, http.StatusNotFound, errorCodeNotFound)
|
||||
}
|
||||
|
||||
func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest())
|
||||
@@ -1297,11 +1413,11 @@ func newTestRouter() http.Handler {
|
||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return NewRouterWithCore(core)
|
||||
return NewTestRouterWithCore(core)
|
||||
}
|
||||
|
||||
func apiRouterWithoutSeededAdmin() http.Handler {
|
||||
return NewRouterWithCore(service.NewCoreService(repo.NewMemoryStore()))
|
||||
return NewTestRouterWithCore(service.NewCoreService(repo.NewMemoryStore()))
|
||||
}
|
||||
|
||||
func postJSON[T any](t *testing.T, router http.Handler, path string, body any) T {
|
||||
@@ -1501,6 +1617,11 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityLogsBackfill,
|
||||
domain.JobCapabilityClientManagerDeploy,
|
||||
domain.JobCapabilityClientManagerControl,
|
||||
domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback,
|
||||
domain.JobCapabilityClientManagerUninstall,
|
||||
}
|
||||
pluginRequest.DeclaredPermissions = []string{
|
||||
"server.read",
|
||||
@@ -1516,16 +1637,26 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
||||
string(domain.PluginBridgeActionDependenciesRequest),
|
||||
string(domain.PluginBridgeActionLogsBackfillRequest),
|
||||
}
|
||||
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
||||
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
||||
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||
|
||||
endpoint := validRunEndpointRequest()
|
||||
endpoint.ID = "run-runtime"
|
||||
endpoint.Platform = "linux"
|
||||
endpoint.Architecture = "amd64"
|
||||
endpoint.Capabilities = append(endpoint.Capabilities,
|
||||
domain.JobCapabilityDistributionBuild,
|
||||
domain.JobCapabilityRunSelfUpdate,
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityLogsBackfill,
|
||||
domain.JobCapabilityClientManagerDeploy,
|
||||
domain.JobCapabilityClientManagerControl,
|
||||
domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback,
|
||||
domain.JobCapabilityClientManagerUninstall,
|
||||
)
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
|
||||
|
||||
@@ -1536,6 +1667,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
||||
Name: "Runtime API Server",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
}, adminSession)
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
||||
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||
ID: "log-runtime-api",
|
||||
ServerInstanceID: server.ID,
|
||||
@@ -1594,6 +1726,7 @@ func validGamePluginRequest() dto.GamePluginCreateRequest {
|
||||
Start: "actions/start.json",
|
||||
Stop: "actions/stop.json",
|
||||
},
|
||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1638,7 +1771,8 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||
},
|
||||
},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}},
|
||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+40
-4
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/config"
|
||||
@@ -27,18 +28,53 @@ func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
core := service.NewCoreServiceWithLogStore(store, logStore)
|
||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||
artifactDir := strings.TrimSpace(cfg.ArtifactDir)
|
||||
if artifactDir == "" {
|
||||
if strings.TrimSpace(cfg.DataDir) != "" {
|
||||
artifactDir = filepath.Join(cfg.DataDir, "artifacts")
|
||||
} else {
|
||||
artifactDir = filepath.Join(filepath.Dir(cfg.MetadataPath), "artifacts")
|
||||
}
|
||||
}
|
||||
artifactStore, err := service.NewFileArtifactBodyStore(artifactDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewRouterWithCore(core), nil
|
||||
core, err := service.NewCoreServiceWithDurableStores(store, logStore, artifactStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.BootstrapAdminPassword) != "" {
|
||||
if err := core.SeedPlatformAdmin(cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return NewAuthorizedRouterWithCore(core), nil
|
||||
}
|
||||
|
||||
func NewRouterWithCore(core service.Core) http.Handler {
|
||||
handlers := newCoreHandlers(core)
|
||||
return newRouterWithCore(core, true)
|
||||
}
|
||||
|
||||
func NewAuthorizedRouterWithCore(core service.Core) http.Handler {
|
||||
return newRouterWithCore(core, true)
|
||||
}
|
||||
|
||||
func NewTestRouterWithCore(core service.Core) http.Handler {
|
||||
return newRouterWithCore(core, false)
|
||||
}
|
||||
|
||||
func newRouterWithCore(core service.Core, enforceAuthorization bool) http.Handler {
|
||||
handlers := newCoreHandlers(core, enforceAuthorization)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", HealthHandler)
|
||||
handlers.register(mux)
|
||||
if enforceAuthorization {
|
||||
return handlers.requireAuthorizedAPI(mux)
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
+37
-14
@@ -14,7 +14,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
||||
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
|
||||
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
|
||||
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
|
||||
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyJobRequest`, `LogBackfillRequest` |
|
||||
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest` |
|
||||
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
||||
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
|
||||
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
||||
@@ -43,13 +43,14 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
||||
## Implemented Authentication And Current User Actions
|
||||
|
||||
- `POST /api/v1/auth/register`: accept `RegisterRequest`; the first registered account becomes an active platform administrator with an authenticated session, while later registrations create pending low-privilege users and return `AuthSessionResponse` with `status=pending` and no session token.
|
||||
- `POST /api/v1/auth/login`: accept `LoginRequest`, authenticate an active user by ID or email, and return `AuthSessionResponse` with a bearer session token.
|
||||
- `POST /api/v1/auth/login`: accept `LoginRequest` and authenticate an active user by ID or email. Strict production routes set an HttpOnly SameSite cookie and omit the raw token from JSON; explicit CLI callers may request a bearer response with `X-Auth-Token-Response: bearer`.
|
||||
- `POST /api/v1/auth/logout`: invalidate the active bearer session token and return `204`.
|
||||
- `POST /api/v1/auth/rotate`: durably revoke the current bearer generation and return a new bounded session token and expiry.
|
||||
- `GET /api/v1/users/current`: return `CurrentUserResponse` for the bearer session.
|
||||
- `PUT /api/v1/users/current/profile`: update bounded current-user profile fields using `UserProfileBody`.
|
||||
- `PUT /api/v1/users/current/theme`: persist current-user console theme preferences using `UserThemePreferenceRequest`.
|
||||
|
||||
Auth responses never expose password hashes or raw credentials. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. Tests and local fixtures may seed one explicit platform administrator account for manual login: `operator.local@example.test` / `operator-local`.
|
||||
Bearer sessions are stored as SHA-256 verifiers with issued/expiry/revocation timestamps and rotation generation; raw tokens are never written to FileStore/MySQLStore snapshots. Browser sessions use HttpOnly SameSite cookies, while explicit CLI bearer mode returns the token once. Production router construction requires authentication for sensitive API paths, reserves user/provider/plugin install/Run endpoint/audit/global create operations for platform administrators, and repeats server/job/log/artifact ownership checks in services. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. A bootstrap administrator is created only when `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD` is explicitly configured; local debug scripts provide their own development-only value.
|
||||
|
||||
## Implemented Role-Scoped Server Access
|
||||
|
||||
@@ -89,6 +90,8 @@ Config write and file dispatch responses expose only logical target keys, scoped
|
||||
|
||||
AI invocation is platform-mediated. Tests and local verification use a deterministic mock provider client; live external provider calls are deferred behind the same interface and are not required for this change. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions are recommendations only and never dispatch run-side writes directly.
|
||||
|
||||
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a controlled secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
||||
|
||||
## Implemented Game Plugin Registry Actions
|
||||
|
||||
- `POST /api/v1/game-plugins/register-manifest`: accept `GamePluginManifestRegistrationRequest`, validate a game management plugin manifest, and persist installed registry metadata using `GamePluginResponse`.
|
||||
@@ -124,21 +127,31 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
||||
|
||||
## Implemented Runtime Distribution And Client Manager Actions
|
||||
|
||||
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
|
||||
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs. Undeclared keys, unsafe paths/sockets/credentials, and changes to an existing active binding are rejected.
|
||||
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
|
||||
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, and redacted secret ref only.
|
||||
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
|
||||
- `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`.
|
||||
- `GET /api/v1/server-instances/{id}/run/update`: lists safe update phase, target, progress message, artifact checksum, release identity, rollback, and audit summary for the authorized server.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/generate`: accepts `ClientManagerBuildRequest`, validates the plugin-declared client-manager profile and target platform, injects a distinct current client-manager key into the package config, publishes a downloadable artifact, and returns `ClientManagerDistributionResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`.
|
||||
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and queues `dependencies.install` only for typed plugin-declared plans.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
||||
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
|
||||
|
||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and run endpoint capability support for run-side jobs. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||
|
||||
`POST /api/v1/server-instances/workflows/create` requires `profileKey` and initial `bindings`. Platform validates completeness and persists the binding before dispatching the install job; the job `targetKey` identifies the selected declared profile. Existing servers without a binding remain readable, but lifecycle and runtime-dependent actions return a safe configuration-required reason.
|
||||
|
||||
## Private Run Dependency And Update Routes
|
||||
|
||||
The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes.
|
||||
|
||||
## Implemented Run Control Actions
|
||||
|
||||
- `POST /api/v1/run/control/hello`: accept `RunControlHelloRequest`, create or update run endpoint metadata, and return `RunControlHelloResponse` with a platform-issued session token.
|
||||
@@ -149,15 +162,15 @@ Control is the highest-priority run-facing channel; artifact/file transfer press
|
||||
|
||||
## Implemented Run Job Actions
|
||||
|
||||
- `POST /api/v1/run/jobs/claim`: accept `RunJobClaimRequest`, validate the active run session, lease one queued job for that endpoint, and return `RunJobClaimResponse`.
|
||||
- `POST /api/v1/run/jobs/ack`: accept `RunJobAckRequest` and move an active leased job into running state.
|
||||
- `POST /api/v1/run/jobs/progress`: accept `RunJobProgressRequest` and update bounded progress metadata.
|
||||
- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal job result.
|
||||
- `POST /api/v1/run/jobs/cancel`: accept `RunJobCancelPollRequest` and return pending cancellation metadata for active leases.
|
||||
- `POST /api/v1/run/jobs/reconcile`: accept `RunJobReconcileRequest` and return platform-known active jobs plus unknown run-reported job IDs.
|
||||
- `POST /api/v1/jobs/{id}/cancel`: accept `RunJobCancelRequestBody` and record a platform cancellation request for run polling.
|
||||
- `POST /api/v1/run/jobs/claim`: accept `RunJobClaimRequest`, validate the active Run session, sweep expired endpoint work, and durably claim one eligible queued/retrying job with a monotonic per-job attempt, hashed lease credential, ack deadline, and execution lease.
|
||||
- `POST /api/v1/run/jobs/ack`: accept `RunJobAckRequest`, fence endpoint/session generation/attempt/lease, reject late acknowledgements, and move the current attempt into running state.
|
||||
- `POST /api/v1/run/jobs/progress`: accept `RunJobProgressRequest`, reject stale sequences and expired/old attempts, persist bounded progress, and renew the current execution lease.
|
||||
- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal result or durable retry-wait transition with capped exponential backoff.
|
||||
- `POST /api/v1/run/jobs/cancel`: accept fenced `RunJobCancelPollRequest` and return durable pending cancellation intent for the current attempt.
|
||||
- `POST /api/v1/run/jobs/reconcile`: accept persisted Run journal evidence (`jobId`, `attempt`, `leaseToken`), rebind only matching active attempts to the current authenticated session generation, persist reconciliation metadata, retry/cancel platform-active missing work, and return confirmed assignments plus discard IDs.
|
||||
- `POST /api/v1/jobs/{id}/cancel`: authorize the server owner/administrator or platform administrator and durably record cancellation intent; queued/retrying work becomes cancelled immediately while active work completes through fenced Run polling/result.
|
||||
|
||||
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt, progress, terminal state, message, error code, result reference, and timing hints. They do not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies.
|
||||
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials.
|
||||
Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure.
|
||||
|
||||
## Implemented Log Ingest Actions
|
||||
@@ -186,7 +199,16 @@ Artifact/file transfer is lower priority than control, job lifecycle metadata, a
|
||||
- `POST /api/v1/artifacts/{id}/download`: returns `ArtifactDownloadReferenceResponse` with filename, content type, size, checksum, expiry, supported chunk size, and a platform-owned `downloadUrl`.
|
||||
- `GET /api/v1/artifacts/{id}/content`: returns a bounded byte range using `offset`/`limit` query parameters or a `Range: bytes=start-end` header. Responses include `Content-Length`, `Accept-Ranges`, optional `Content-Range`, `X-Artifact-Checksum`, `X-Artifact-Content-Checksum`, and `X-Artifact-Storage` headers.
|
||||
|
||||
Browser artifact downloads require an available artifact plus user access to the owning job/server context. Platform/plugin-owned artifacts are limited to platform administrators until a future storage policy adds narrower ownership. Current content reads reconstruct completed upload chunks from the in-memory platform transfer session; durable external storage adapters are deferred behind the same service contract. Browser and plugin pages receive only platform routes and integrity metadata, never raw storage backend URLs, host paths, direct run sockets, run tokens, bearer tokens, or storage credentials.
|
||||
Browser artifact downloads require an available artifact plus user access to the owning job/server context. Platform/plugin-owned artifacts are limited to platform administrators until a future storage policy adds narrower ownership. Current content reads use the private durable artifact body store; external object storage adapters are deferred behind the same service contract. Browser and plugin pages receive only platform routes and integrity metadata, never raw storage backend URLs, host paths, direct run sockets, run tokens, bearer tokens, or storage credentials.
|
||||
|
||||
## Durable Observability And Scoped Remote Adapters
|
||||
|
||||
- `POST /api/v1/run/metrics/batches`: accepts a signed bounded metric batch for the Run endpoint's server instances and returns an acknowledgement count.
|
||||
- `GET /api/v1/metrics/server-instances/history`: returns a bounded owner-scoped metric history by server instance and optional time/limit query.
|
||||
- `GET|POST /api/v1/backups` and `GET /api/v1/backups/{id}`: expose or create safe backup metadata, checksum, artifact reference, retention, and recovery state; body bytes and storage paths remain private.
|
||||
- `GET|POST /api/v1/server-instances/{id}/remote-adapters`: lists manifest-declared adapter capabilities or queues an owner-authorized fenced adapter job using logical target keys. Requests never carry arbitrary shell, socket, host, or credential data.
|
||||
|
||||
Metric, backup, log, and artifact records use the configured durable metadata/body stores. Control, job, log, artifact, metric, and remote adapter traffic remain independent channels; slow artifact or adapter retries do not share lightweight heartbeat or job result payloads.
|
||||
|
||||
## Error Contract
|
||||
|
||||
@@ -207,9 +229,10 @@ These route groups remain documented future work beyond the currently implemente
|
||||
- Authorization policy routes beyond role-scoped navigation and bearer session identity.
|
||||
- Run control transport beyond hello and heartbeat, including heartbeat reconciliation policies.
|
||||
- External metrics collectors, browser tail transport, external log body backends, and AI log analysis windows.
|
||||
- Browser artifact upload, external artifact storage backends, presigned URLs, and production throttling policies.
|
||||
- External artifact storage backends, presigned URLs, and production throttling policies. Run self-update range reads and local artifact upload are implemented, but production mirrors/signing are not.
|
||||
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
|
||||
- Live AI provider connectivity tests and remote model discovery.
|
||||
- Production Run distribution signing/KMS, fleet rollout rings, client-manager lifecycle, plugin lifecycle, production scaling/alerts, and real AI-provider integration.
|
||||
- Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions.
|
||||
|
||||
## Core Service Boundary
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/service"
|
||||
)
|
||||
|
||||
const (
|
||||
runEndpointHeader = "X-Run-Endpoint"
|
||||
runTimestampHeader = "X-Run-Timestamp"
|
||||
runNonceHeader = "X-Run-Nonce"
|
||||
runSignatureHeader = "X-Run-Signature"
|
||||
)
|
||||
|
||||
type runRequestEnvelope struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
}
|
||||
|
||||
func (h *coreHandlers) requireRunSignature(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.Body == nil {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20))
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
var envelope runRequestEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
headerEndpoint := strings.TrimSpace(r.Header.Get(runEndpointHeader))
|
||||
if headerEndpoint != "" && headerEndpoint != envelope.RunEndpointID {
|
||||
writeServiceError(w, service.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
bodySum := sha256.Sum256(body)
|
||||
err = h.core.AuthorizeRunRequestSignature(domain.RunRequestSignature{
|
||||
RunEndpointID: envelope.RunEndpointID,
|
||||
SessionToken: envelope.SessionToken,
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Timestamp: strings.TrimSpace(r.Header.Get(runTimestampHeader)),
|
||||
Nonce: strings.TrimSpace(r.Header.Get(runNonceHeader)),
|
||||
BodyHash: hex.EncodeToString(bodySum[:]),
|
||||
Signature: strings.TrimSpace(r.Header.Get(runSignatureHeader)),
|
||||
})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -98,3 +98,35 @@ func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
|
||||
}
|
||||
|
||||
// serverInstanceProcessStatus godoc
|
||||
// @Summary Query supervised server process status
|
||||
// @Description Queues a typed process.status job for the selected server/profile.
|
||||
// @Tags server-instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ServerLifecycleCommandRequest true "Process status request"
|
||||
// @Success 202 {object} dto.ServerLifecycleResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/process/status [post]
|
||||
func (h *coreHandlers) serverInstanceProcessStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.QueryServerInstanceProcessForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,48 @@ import (
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// serverRuntimeBinding godoc
|
||||
// @Summary Review or update a server runtime binding
|
||||
// @Description GET returns redacted logical readiness metadata. PUT changes the selected declared profile and logical refs for an authorized owner or platform administrator.
|
||||
// @Tags server-instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.RuntimeBindingUpdateRequest false "Runtime binding update"
|
||||
// @Success 200 {object} dto.RuntimeBindingResponse
|
||||
// @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}/runtime-binding [get]
|
||||
// @Router /api/v1/server-instances/{id}/runtime-binding [put]
|
||||
func (h *coreHandlers) serverRuntimeBinding(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
binding, err := h.core.GetServerRuntimeBindingForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.RuntimeBindingFromDomain(binding))
|
||||
case http.MethodPut:
|
||||
request, err := decodeJSON[dto.RuntimeBindingUpdateRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
binding, err := h.core.UpdateServerRuntimeBindingForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.RuntimeBindingFromDomain(binding))
|
||||
default:
|
||||
writeMethodNotAllowed(w, "GET, PUT")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverRuntimeActions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
@@ -65,8 +107,17 @@ func (h *coreHandlers) serverRunKeyReset(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverRunUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
jobs, err := h.core.ListRunUpdateJobsForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.RunUpdateJobListFromDomain(jobs))
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
writeMethodNotAllowed(w, "GET, POST")
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.RunUpdateRequest](r)
|
||||
@@ -82,6 +133,28 @@ func (h *coreHandlers) serverRunUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusAccepted, dto.RunUpdateJobFromDomain(job))
|
||||
}
|
||||
|
||||
// serverDependencies godoc
|
||||
// @Summary List declared dependency probes, reviewable install plans, and safe status
|
||||
// @Description Returns only target, digest, typed step, and bounded dependency evidence for an authorized server user.
|
||||
// @Tags server-instances
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.DependencyCatalogResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/dependencies [get]
|
||||
func (h *coreHandlers) serverDependencies(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
catalog, err := h.core.GetDependencyCatalogForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.DependencyCatalogFromDomain(catalog))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverClientManagerGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
const platformSessionCookieName = "platform_session"
|
||||
|
||||
func (h *coreHandlers) writeAuthSession(w http.ResponseWriter, r *http.Request, session domain.AuthSession) {
|
||||
response := dto.AuthSessionFromDomain(session)
|
||||
if h.enforceAuthorization && strings.TrimSpace(session.SessionID) != "" {
|
||||
h.setSessionCookie(w, r, session.SessionID, session.ExpiresAt)
|
||||
if r.Header.Get("X-Auth-Token-Response") != "bearer" {
|
||||
response.SessionID = ""
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: platformSessionCookieName,
|
||||
Value: token,
|
||||
Path: "/api/v1",
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *coreHandlers) clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: platformSessionCookieName,
|
||||
Value: "",
|
||||
Path: "/api/v1",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user