From 5fbe8092b9bc371efe6e1f59c02e7bd658858eea Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 4 Aug 2026 17:14:28 +0800 Subject: [PATCH] fix server creation run binding --- platform/api/resource_handlers_test.go | 26 +++++++----- platform/api/routes.md | 2 +- platform/api/server_lifecycle_handlers.go | 4 ++ platform/dto/server_lifecycle.go | 41 ++++++++++++++++--- platform_web/api/client.test.ts | 2 +- platform_web/api/contracts.md | 2 +- platform_web/api/types.ts | 4 -- .../ServerDeploymentWorkflow.test.tsx | 6 ++- platform_web/pages/ConsolePages.test.tsx | 5 +++ platform_web/pages/ServersPage.tsx | 4 +- platform_web/schemas/serverManagement.test.ts | 13 +++--- platform_web/schemas/serverManagement.ts | 6 --- scripts/local-debug/smoke.sh | 18 ++------ 13 files changed, 80 insertions(+), 53 deletions(-) diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index 2bb240a..b525bf9 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -783,14 +783,22 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) { created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ ID: "server-create", PluginID: "server.scum", - 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 { + if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateDraft || created.Job.ID != "" || created.Instance.RunEndpointID != "" { t.Fatalf("expected create workflow response, got %+v", created) } + legacyCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ + ID: "server-create-legacy", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "SCUM Legacy Create", + IdempotencyKey: "idem-create-legacy", + ProfileKey: "local", + Bindings: map[string]string{"server-root": "runtime.server-root"}, + }, adminSession) + assertErrorResponse(t, legacyCreate, http.StatusBadRequest, errorCodeValidation) ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-ready", @@ -844,7 +852,7 @@ func TestServerDeploymentRevealAPIIsExplicitAndOwnerScoped(t *testing.T) { endpoint := validRunEndpointRequest() endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint) - created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "deployment-reveal", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Reveal", IdempotencyKey: "deployment-reveal", ProfileKey: "local", Deployment: dto.ServerDeploymentRequest{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/reveal", WorkingDirectory: "/srv/reveal", StartCommand: "./start-server"}}, adminSession) + postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "deployment-reveal", PluginID: "server.scum", Name: "Reveal", IdempotencyKey: "deployment-reveal", Deployment: dto.ServerDeploymentRequest{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/reveal", WorkingDirectory: "/srv/reveal", StartCommand: "./start-server"}}, adminSession) redactedRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment", "", adminSession) assertStatus(t, redactedRecorder, http.StatusOK) @@ -852,8 +860,8 @@ func TestServerDeploymentRevealAPIIsExplicitAndOwnerScoped(t *testing.T) { t.Fatalf("normal deployment view leaked protected inputs: %s", body) } redacted := decodeBody[dto.ServerDeploymentResponse](t, redactedRecorder) - if redacted.LatestDispatch == nil || !redacted.LatestDispatch.DeploymentDefinitionIncluded || redacted.LatestDispatch.JobID != created.Job.ID || redacted.LatestDispatch.DeploymentRevision != 1 { - t.Fatalf("expected safe deployment dispatch evidence, got %+v", redacted.LatestDispatch) + if redacted.LatestDispatch != nil { + t.Fatalf("create-time deployment settings must not dispatch to Run, got %+v", redacted.LatestDispatch) } revealed := getJSONWithAuth[dto.ServerDeploymentRevealResponse](t, router, "/api/v1/server-instances/deployment-reveal/deployment/reveal", adminSession) @@ -1631,10 +1639,8 @@ func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) { 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 != "actions/install.json" { - t.Fatalf("create workflow did not dispatch selected profile: %+v", created.Job) - } + legacyCreate := requestJSONWithAuth(t, router, http.MethodPost, "/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) + assertErrorResponse(t, legacyCreate, http.StatusBadRequest, errorCodeValidation) 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) diff --git a/platform/api/routes.md b/platform/api/routes.md index cb64cc8..98f9f1a 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -137,7 +137,7 @@ Artifact bridge execution returns safe metadata and platform content routes only ## Implemented Server Lifecycle Actions -- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation starts from `pluginId` and `name`, and may include the create-wizard deployment definition such as deployment mode, plugin create inputs, server root, or custom start command. The browser must not require a deployment target, run endpoint, or runtime profile at creation; those bindings remain post-creation/runtime-registration concerns. Legacy `runEndpointId` and `deploymentTargetId` inputs remain accepted for compatible programmatic flows, but are never prerequisites for creation and do not select a distribution builder. +- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation starts from `pluginId` and `name`, and may include the create-wizard deployment definition such as deployment mode, plugin create inputs, server root, or custom start command. The browser must not require or submit a deployment target, run endpoint, runtime profile, or runtime bindings during creation; those bindings remain post-creation/runtime-registration concerns. Requests that include `deploymentTargetId`, `runEndpointId`, `profileKey`, `bindings`, `deployment.runEndpointId`, `deployment.profileKey`, or `deployment.runtimeBindings` are rejected. - `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`. - `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`. diff --git a/platform/api/server_lifecycle_handlers.go b/platform/api/server_lifecycle_handlers.go index ec3dd13..ed8c390 100644 --- a/platform/api/server_lifecycle_handlers.go +++ b/platform/api/server_lifecycle_handlers.go @@ -29,6 +29,10 @@ func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *ht writeDecodeError(w, err) return } + if err := request.ValidateCreateOnly(); err != nil { + writeServiceError(w, err) + return + } result, err := h.core.CreateServerInstanceWorkflowForSession(bearerToken(r), request.ToDomain()) if err != nil { writeServiceError(w, err) diff --git a/platform/dto/server_lifecycle.go b/platform/dto/server_lifecycle.go index d8a0ee2..61bca37 100644 --- a/platform/dto/server_lifecycle.go +++ b/platform/dto/server_lifecycle.go @@ -1,9 +1,11 @@ package dto import ( + "strings" "time" "browser.local/platform/domain" + "browser.local/platform/validator" ) type ServerDeploymentRequest struct { @@ -78,7 +80,7 @@ type ServerLifecycleCreateRequest struct { ID string `json:"id"` PluginID string `json:"pluginId"` DeploymentTargetID string `json:"deploymentTargetId,omitempty"` - RunEndpointID string `json:"runEndpointId"` + RunEndpointID string `json:"runEndpointId,omitempty"` Name string `json:"name"` OwnerUserID string `json:"ownerUserId,omitempty"` IdempotencyKey string `json:"idempotencyKey"` @@ -87,6 +89,35 @@ type ServerLifecycleCreateRequest struct { Deployment ServerDeploymentRequest `json:"deployment,omitempty"` } +func (request ServerLifecycleCreateRequest) ValidateCreateOnly() error { + var violations []string + if strings.TrimSpace(request.DeploymentTargetID) != "" { + violations = append(violations, "deploymentTargetId must not be provided during server creation") + } + if strings.TrimSpace(request.RunEndpointID) != "" { + violations = append(violations, "runEndpointId must not be provided during server creation") + } + if strings.TrimSpace(request.ProfileKey) != "" { + violations = append(violations, "profileKey must not be provided during server creation") + } + if len(request.Bindings) > 0 { + violations = append(violations, "bindings must not be provided during server creation") + } + if strings.TrimSpace(request.Deployment.RunEndpointID) != "" { + violations = append(violations, "deployment.runEndpointId must not be provided during server creation") + } + if strings.TrimSpace(request.Deployment.ProfileKey) != "" { + violations = append(violations, "deployment.profileKey must not be provided during server creation") + } + if len(request.Deployment.RuntimeBindings) > 0 { + violations = append(violations, "deployment.runtimeBindings must not be provided during server creation") + } + if len(violations) > 0 { + return validator.ValidationError{Violations: violations} + } + return nil +} + type ServerLifecycleCommandRequest struct { ExpectedConfigVersion int `json:"expectedConfigVersion"` IdempotencyKey string `json:"idempotencyKey"` @@ -103,13 +134,13 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre return domain.ServerLifecycleCreate{ ID: request.ID, PluginID: request.PluginID, - DeploymentTargetID: request.DeploymentTargetID, - RunEndpointID: request.RunEndpointID, + DeploymentTargetID: "", + RunEndpointID: "", Name: request.Name, OwnerUserID: request.OwnerUserID, IdempotencyKey: request.IdempotencyKey, - ProfileKey: request.ProfileKey, - Bindings: domain.CopyStringMap(request.Bindings), + ProfileKey: "", + Bindings: nil, Deployment: request.Deployment.deploymentDefinition(), } } diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 8bb12f3..fd9627a 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -621,7 +621,7 @@ describe("PlatformApiClient AI providers", () => { await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] }); await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true }); await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum }); - await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create", profileKey: "local", bindings: {} })).resolves.toMatchObject({ + await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({ action: "create" }); await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" }); diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index b4dba99..e88a034 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -22,7 +22,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia ## Server Management Workflows -- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with the create-wizard deployment definition to `/server-instances/workflows/create`, including deployment mode, plugin create inputs, and custom startup fields when provided. It does not require deployment target, run endpoint, or runtime profile selection during creation. +- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with the create-wizard deployment definition to `/server-instances/workflows/create`, including deployment mode, plugin create inputs, and custom startup fields when provided. It must not include deployment target, run endpoint, runtime profile, or runtime bindings during creation; those are established only after creation through generated Run registration, runtime binding, or deployment update flows. - `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values. - `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 05dda1d..abc0590 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -505,12 +505,8 @@ export interface ServerInstanceListResponse { export interface ServerLifecycleCreateRequest { id: string; pluginId: string; - deploymentTargetId?: string; - runEndpointId?: string; name: string; idempotencyKey: string; - profileKey?: string; - bindings?: Record; deployment?: ServerDeploymentRequest; } diff --git a/platform_web/components/ServerDeploymentWorkflow.test.tsx b/platform_web/components/ServerDeploymentWorkflow.test.tsx index 9c0693e..42bee22 100644 --- a/platform_web/components/ServerDeploymentWorkflow.test.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.test.tsx @@ -116,13 +116,17 @@ describe("ServerDeploymentWorkflow", () => { pluginId: "game.runtime", name: "Custom Runtime Server", idempotencyKey: "web:create:server-custom-runtime-server-17:17", - runEndpointId: undefined, deployment: { mode: "custom-command", serverRoot: "/srv/custom-runtime", startCommand: "./start-runtime.sh" } }); + expect(submitted && "runEndpointId" in submitted).toBe(false); + expect(submitted && "profileKey" in submitted).toBe(false); + expect(submitted && "bindings" in submitted).toBe(false); + expect(submitted?.deployment && "profileKey" in submitted.deployment).toBe(false); + expect(submitted?.deployment && "runtimeBindings" in submitted.deployment).toBe(false); }); }); diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index e53422d..7e77396 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -11,6 +11,7 @@ import { UsersPage } from "./UsersPage"; import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw"; import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw"; import serverLiveOperationsSource from "../components/ServerLiveOperations.tsx?raw"; +import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw"; import serversPageSource from "./ServersPage.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; import type { PageComponentProps } from "../contracts/page"; @@ -239,6 +240,10 @@ describe("first-party console pages", () => { expect(serverDeploymentWorkflowSource).toContain('const configurationStep = kind === "create" ? 2 : needsTargetSelection ? 1 : 0;'); expect(serverDeploymentWorkflowSource).toContain('const needsTargetSelection = kind === "edit" && !initialForm.runEndpointId;'); expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)"); + expect(serverCreateSchemaSource).not.toContain("runEndpointId: form.runEndpointId"); + expect(serverCreateSchemaSource).not.toContain("deploymentTargetId: form.deploymentTargetId"); + expect(serverCreateSchemaSource).not.toContain("profileKey: form.profileKey"); + expect(serverCreateSchemaSource).not.toContain("runtimeBindings:"); }); it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => { diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 5c7a7a8..6dad0bc 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -107,9 +107,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr pluginId: plugin?.id ?? "", profileKey, bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {}, - runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId) - ? current.runEndpointId - : endpointResponse.items[0]?.id || "" + runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId) ? current.runEndpointId : "" }; }); if (showLoading) { diff --git a/platform_web/schemas/serverManagement.test.ts b/platform_web/schemas/serverManagement.test.ts index fec18ca..ebda958 100644 --- a/platform_web/schemas/serverManagement.test.ts +++ b/platform_web/schemas/serverManagement.test.ts @@ -53,7 +53,7 @@ describe("runtime profile server creation contracts", () => { expect(runtimeBindingFields(plugin, "local").some((field) => field.key === "ftp.profile")).toBe(false); }); - it("selects the plugin profile and submits real profile bindings", () => { + it("submits deployment inputs without binding a Run or runtime profile", () => { const form = defaultServerCreateForm([plugin], []); expect(form.profileKey).toBe("local"); expect( @@ -69,15 +69,10 @@ describe("runtime profile server creation contracts", () => { ).toEqual({ id: "server-1", pluginId: "game.runtime", - runEndpointId: undefined, name: "Runtime Server", idempotencyKey: "web:create:server-1:17", - profileKey: "local", - bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" }, deployment: { mode: "guided-install", - profileKey: "local", - runtimeBindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" }, createInputs: { gamePort: "7777", maxPlayers: "64" } } }); @@ -117,7 +112,11 @@ describe("runtime profile server creation contracts", () => { it("keeps complete paths and commands in a write-only deployment payload", () => { const form = defaultServerCreateForm([plugin], []); const request = serverCreateRequestFromForm({ ...form, name: "Venv Server", deploymentMode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py", shell: "" }, 19); - expect(request.runEndpointId).toBeUndefined(); + expect("runEndpointId" in request).toBe(false); + expect("profileKey" in request).toBe(false); + expect("bindings" in request).toBe(false); expect(request.deployment).toMatchObject({ mode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py" }); + expect("profileKey" in (request.deployment ?? {})).toBe(false); + expect("runtimeBindings" in (request.deployment ?? {})).toBe(false); }); }); diff --git a/platform_web/schemas/serverManagement.ts b/platform_web/schemas/serverManagement.ts index b317088..de4a8f4 100644 --- a/platform_web/schemas/serverManagement.ts +++ b/platform_web/schemas/serverManagement.ts @@ -26,16 +26,10 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc return { id, pluginId: form.pluginId.trim(), - deploymentTargetId: form.deploymentTargetId.trim() || undefined, - runEndpointId: form.runEndpointId.trim() || undefined, name: form.name.trim(), idempotencyKey: lifecycleIdempotencyKey("create", id, sequence), - profileKey: form.profileKey.trim() || undefined, - bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")), deployment: { mode: form.deploymentMode, - profileKey: form.profileKey.trim() || undefined, - runtimeBindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")), createInputs: Object.fromEntries(Object.entries(form.createInputs).map(([key, value]) => [key, value.trim()])), serverRoot: form.serverRoot.trim() || undefined, workingDirectory: form.workingDirectory.trim() || undefined, diff --git a/scripts/local-debug/smoke.sh b/scripts/local-debug/smoke.sh index 65934bc..6c60044 100755 --- a/scripts/local-debug/smoke.sh +++ b/scripts/local-debug/smoke.sh @@ -847,11 +847,8 @@ cat >"$WORK_DIR/create-scum-beta.request.json" <"$WORK_DIR/create-scum-dynamic.request.json" <