first commit

This commit is contained in:
npc0-hue
2026-07-11 14:56:10 +08:00
commit 7e05d0a4e7
660 changed files with 78119 additions and 0 deletions
@@ -0,0 +1,177 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestArtifactDownloadAPIWorkflowIsPlatformMediated(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download API"}, adminSession)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-api", ServerInstanceID: "server-download-api", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-api"})
payload := []byte("artifact payload for browser mediated download")
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-api", "job-download-api", payload, 9)
reference := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/artifacts/artifact-download-api/download", map[string]string{}, adminSession)
if reference.ArtifactID != "artifact-download-api" || reference.DownloadURL != "/api/v1/artifacts/artifact-download-api/content" || !reference.RangeSupported || reference.ChunkSizeBytes != validator.MaxArtifactDownloadBytes {
t.Fatalf("unexpected artifact reference: %+v", reference)
}
if reference.Checksum != validator.BytesChecksum(payload) || reference.SizeBytes != int64(len(payload)) || reference.StorageBehavior == "" {
t.Fatalf("expected integrity metadata in reference, got %+v", reference)
}
contentRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-api/content?offset=9&limit=7", "", adminSession)
assertStatus(t, contentRecorder, http.StatusPartialContent)
if got, want := contentRecorder.Body.Bytes(), payload[9:16]; !bytes.Equal(got, want) {
t.Fatalf("expected range payload %q, got %q", want, got)
}
if contentRecorder.Header().Get("Content-Range") != "bytes 9-15/46" || contentRecorder.Header().Get("X-Artifact-Checksum") != validator.BytesChecksum(payload) || contentRecorder.Header().Get("X-Artifact-Content-Checksum") != validator.BytesChecksum(payload[9:16]) {
t.Fatalf("expected safe integrity headers, got %+v", contentRecorder.Header())
}
rangeRequest := httptest.NewRequest(http.MethodGet, "/api/v1/artifacts/artifact-download-api/content", nil)
rangeRequest.Header.Set("Authorization", "Bearer "+adminSession)
rangeRequest.Header.Set("Range", "bytes=0-7")
rangeRecorder := httptest.NewRecorder()
router.ServeHTTP(rangeRecorder, rangeRequest)
assertStatus(t, rangeRecorder, http.StatusPartialContent)
if !bytes.Equal(rangeRecorder.Body.Bytes(), payload[:8]) {
t.Fatalf("expected range header payload, got %q", rangeRecorder.Body.String())
}
for _, body := range []string{mustJSON(t, reference), contentRecorder.Header().Get("Content-Disposition"), contentRecorder.Header().Get("X-Artifact-Storage")} {
assertNoArtifactForbiddenFragments(t, body)
}
}
func TestArtifactDownloadAPIDeniesUnavailableAndUnauthorizedArtifacts(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
ID: "user-download-other",
DisplayName: "Other Operator",
Email: "download-other@example.test",
Roles: []string{"server-admin"},
Password: "secret-password",
}, adminSession)
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "download-other@example.test", Password: "secret-password"}).SessionID
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-denied", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download Denied"}, adminSession)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-denied", ServerInstanceID: "server-download-denied", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-denied"})
payload := []byte("download denied payload")
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-denied", "job-download-denied", payload, 8)
unauthorized := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/artifacts/artifact-download-denied/download", map[string]string{}, otherSession)
assertErrorResponse(t, unauthorized, http.StatusForbidden, errorCodeForbidden)
unauthorizedContent := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-denied/content?limit=8", "", otherSession)
assertErrorResponse(t, unauthorizedContent, http.StatusForbidden, errorCodeForbidden)
unavailable := postJSON[dto.ArtifactResponse](t, router, "/api/v1/artifacts", dto.ArtifactCreateRequest{ID: "artifact-uploading-denied", OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-download-denied", SizeBytes: 12, Checksum: validator.BytesChecksum([]byte("not-complete!"))})
if unavailable.State != domain.ArtifactStateUploading {
t.Fatalf("expected uploading metadata, got %+v", unavailable)
}
unavailableDownload := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/artifacts/artifact-uploading-denied/download", map[string]string{}, adminSession)
assertErrorResponse(t, unavailableDownload, http.StatusBadRequest, errorCodeValidation)
for _, body := range []string{unauthorized.Body.String(), unauthorizedContent.Body.String(), unavailableDownload.Body.String()} {
assertNoArtifactForbiddenFragments(t, body)
}
}
func TestPluginBridgeArtifactOpenReturnsSafeReference(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.Bridge.Actions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionArtifactsOpen)}
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.artifacts.read"}
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionArtifactsOpen)}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-bridge-artifact", PluginID: "game.example", RunEndpointID: "run-local", Name: "Bridge Artifact"}, adminSession)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-bridge-artifact", ServerInstanceID: instance.ID, RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-bridge-artifact"})
payload := []byte("bridge artifact reference payload")
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-bridge-open", "job-bridge-artifact", payload, 8)
bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
RequestID: "bridge-artifact-open",
PluginID: "game.example",
RouteKey: "logs",
ServerInstanceID: instance.ID,
Action: string(domain.PluginBridgeActionArtifactsOpen),
Payload: map[string]string{"artifactId": "artifact-bridge-open"},
}, adminSession)
if bridge.Status != "ok" || bridge.Result["downloadUrl"] != "/api/v1/artifacts/artifact-bridge-open/content" || bridge.Result["sizeBytes"] == "" || bridge.Result["checksum"] != validator.BytesChecksum(payload) {
t.Fatalf("expected safe artifact reference through bridge, got %+v", bridge)
}
assertNoArtifactForbiddenFragments(t, mustJSON(t, bridge))
}
func uploadCompletedArtifact(t *testing.T, router http.Handler, sessionToken string, artifactID string, jobID string, payload []byte, chunkSize int) dto.ArtifactTransferCompleteResponse {
t.Helper()
open := dto.ArtifactTransferOpenRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
ArtifactID: artifactID,
Direction: domain.ArtifactTransferDirectionUpload,
OwnerKind: domain.ArtifactOwnerKindJob,
OwnerID: jobID,
SizeBytes: int64(len(payload)),
ChunkSizeBytes: chunkSize,
Checksum: validator.BytesChecksum(payload),
IdempotencyKey: artifactID + "-upload",
}
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, performArtifactTransferOpen(t, router, open))
for index := 0; index < opened.TotalChunks; index++ {
offset := index * chunkSize
end := offset + chunkSize
if end > len(payload) {
end = len(payload)
}
part := payload[offset:end]
chunk := dto.ArtifactChunkUploadRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
TransferID: opened.TransferID,
ArtifactID: artifactID,
ChunkIndex: index,
Offset: int64(offset),
SizeBytes: len(part),
Checksum: validator.BytesChecksum(part),
Payload: part,
}
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", chunk), http.StatusOK)
}
completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: artifactID, Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
assertStatus(t, completeRecorder, http.StatusOK)
return decodeBody[dto.ArtifactTransferCompleteResponse](t, completeRecorder)
}
func artifactDownloadHelloRequest() dto.RunControlHelloRequest {
request := validRunControlHelloRequest()
request.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}
request.CapabilityReport.Fingerprint = "cap-artifact-download"
return request
}
func assertNoArtifactForbiddenFragments(t *testing.T, body string) {
t.Helper()
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "storage://", "file://"} {
if strings.Contains(body, forbidden) {
t.Fatalf("artifact response exposed forbidden fragment %q: %s", forbidden, body)
}
}
}
@@ -0,0 +1,144 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestArtifactTransferAPIWorkflow(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
payload := []byte("artifact payload for api upload")
openRequest := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", openRequest)
assertStatus(t, openRecorder, http.StatusOK)
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder)
if !opened.Accepted || opened.TransferID == "" || opened.TotalChunks != 4 || opened.Artifact.State != domain.ArtifactStateUploading {
t.Fatalf("unexpected open response: %+v", opened)
}
chunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
assertStatus(t, chunkRecorder, http.StatusOK)
chunk := decodeBody[dto.ArtifactChunkUploadResponse](t, chunkRecorder)
if !chunk.Accepted || chunk.NextMissingChunkIndex != 1 || len(chunk.ReceivedChunkIndexes) != 1 {
t.Fatalf("unexpected chunk response: %+v", chunk)
}
duplicateRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
assertStatus(t, duplicateRecorder, http.StatusOK)
duplicate := decodeBody[dto.ArtifactChunkUploadResponse](t, duplicateRecorder)
if !duplicate.Duplicate {
t.Fatalf("expected duplicate chunk ack, got %+v", duplicate)
}
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/status", dto.ArtifactTransferStatusRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1"})
assertStatus(t, statusRecorder, http.StatusOK)
status := decodeBody[dto.ArtifactTransferStatusResponse](t, statusRecorder)
if status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 {
t.Fatalf("unexpected status response: %+v", status)
}
missingComplete := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
assertErrorResponse(t, missingComplete, http.StatusBadRequest, errorCodeValidation)
for index := 1; index < opened.TotalChunks; index++ {
partRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, index, 8))
assertStatus(t, partRecorder, http.StatusOK)
}
completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
assertStatus(t, completeRecorder, http.StatusOK)
complete := decodeBody[dto.ArtifactTransferCompleteResponse](t, completeRecorder)
if !complete.Accepted || !complete.Completed || complete.Artifact.State != domain.ArtifactStateAvailable {
t.Fatalf("unexpected complete response: %+v", complete)
}
}
func TestArtifactTransferAPIErrors(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
payload := []byte("artifact payload")
openRequest := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, performArtifactTransferOpen(t, router, openRequest))
badChunk := validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8)
badChunk.Checksum = validator.BytesChecksum([]byte("different"))
badChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", badChunk)
assertErrorResponse(t, badChunkRecorder, http.StatusBadRequest, errorCodeValidation)
invalidSession := validArtifactTransferOpenRequest("stale-token", payload, 8)
invalidSession.ArtifactID = "artifact-invalid-session"
invalidSession.IdempotencyKey = "artifact-invalid-session"
invalidSessionRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", invalidSession)
assertErrorResponse(t, invalidSessionRecorder, http.StatusBadRequest, errorCodeValidation)
invalidOwner := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
invalidOwner.OwnerKind = domain.ArtifactOwnerKindPlatform
invalidOwner.ArtifactID = "artifact-invalid-owner"
invalidOwner.IdempotencyKey = "artifact-invalid-owner"
invalidOwnerRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", invalidOwner)
assertErrorResponse(t, invalidOwnerRecorder, http.StatusBadRequest, errorCodeValidation)
methodFailure := performRaw(t, router, http.MethodGet, "/api/v1/run/artifacts/open", "")
assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed)
}
func createArtifactTransferAPIFixtures(t *testing.T, router http.Handler) dto.RunControlHelloResponse {
t.Helper()
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.install", "process.start", "process.stop", "logs.read", "files.read")
helloRequest.CapabilityReport.Fingerprint = "cap-artifacts"
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1"}, adminSession)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-1", ServerInstanceID: "server-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-start"})
return hello
}
func performArtifactTransferOpen(t *testing.T, router http.Handler, request dto.ArtifactTransferOpenRequest) *httptest.ResponseRecorder {
t.Helper()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", request)
assertStatus(t, recorder, http.StatusOK)
return recorder
}
func validArtifactTransferOpenRequest(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest {
return dto.ArtifactTransferOpenRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
ArtifactID: "artifact-1",
Direction: domain.ArtifactTransferDirectionUpload,
OwnerKind: domain.ArtifactOwnerKindJob,
OwnerID: "job-1",
SizeBytes: int64(len(payload)),
ChunkSizeBytes: chunkSize,
Checksum: validator.BytesChecksum(payload),
IdempotencyKey: "artifact-upload-1",
}
}
func validArtifactChunkRequest(sessionToken string, transferID string, payload []byte, index int, chunkSize int) dto.ArtifactChunkUploadRequest {
offset := index * chunkSize
end := offset + chunkSize
if end > len(payload) {
end = len(payload)
}
part := payload[offset:end]
return dto.ArtifactChunkUploadRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
TransferID: transferID,
ArtifactID: "artifact-1",
ChunkIndex: index,
Offset: int64(offset),
SizeBytes: len(part),
Checksum: validator.BytesChecksum(part),
Payload: part,
}
}
@@ -0,0 +1,241 @@
package api
import (
"fmt"
"net/http"
"strings"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestRunChannelAPIInterleavedRequestsMutateIndependentState(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
createOnlyLogStreamForChannelIsolation(t, router)
claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{"process.start"},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, claimRecorder, http.StatusOK)
claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder)
if !claim.HasJob || claim.Job.JobID != "job-1" {
t.Fatalf("expected claimed job, got %+v", claim)
}
payload := []byte("interleaved artifact payload for api isolation")
openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", artifactOpenForChannelIsolation(hello.SessionToken, payload, 8))
assertStatus(t, openRecorder, http.StatusOK)
open := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder)
firstChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", artifactChunkForChannelIsolation(hello.SessionToken, open.TransferID, payload, 0, 8))
assertStatus(t, firstChunkRecorder, http.StatusOK)
firstChunk := decodeBody[dto.ArtifactChunkUploadResponse](t, firstChunkRecorder)
if !firstChunk.Accepted || firstChunk.NextMissingChunkIndex != 1 {
t.Fatalf("expected first artifact chunk ack, got %+v", firstChunk)
}
heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-artifacts",
Capacity: dto.RunCapacityResponse{MaxJobs: 4, RunningJobs: 1},
})
assertStatus(t, heartbeatRecorder, http.StatusOK)
heartbeat := decodeBody[dto.RunControlHeartbeatResponse](t, heartbeatRecorder)
if !heartbeat.Accepted {
t.Fatalf("expected heartbeat accepted, got %+v", heartbeat)
}
ack := postRunJobAck(t, router, dto.RunJobAckRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Message: "job accepted while artifact transfer is active",
})
if ack.Job.State != domain.JobStateRunning {
t.Fatalf("expected running job after ack, got %+v", ack)
}
logBatch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
logBatch.LogStreamID = "log-channel-isolation"
logRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", logBatch)
assertStatus(t, logRecorder, http.StatusOK)
logAck := decodeBody[dto.LogBatchIngestResponse](t, logRecorder)
if !logAck.Accepted || logAck.LatestSeq != 1 {
t.Fatalf("expected log ack independent from artifact transfer, got %+v", logAck)
}
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateSucceeded,
Progress: dto.JobProgressBody{Percent: 100, Message: "done"},
ResultRef: "artifact://jobs/job-1/result",
Message: "done",
})
assertStatus(t, resultRecorder, http.StatusOK)
result := decodeBody[dto.RunJobResultResponse](t, resultRecorder)
if result.Job.State != domain.JobStateSucceeded || result.Job.ResultRef == "" {
t.Fatalf("expected terminal result independent from transfer, got %+v", result)
}
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/status", dto.ArtifactTransferStatusRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
TransferID: open.TransferID,
ArtifactID: "artifact-channel-isolation",
})
assertStatus(t, statusRecorder, http.StatusOK)
status := decodeBody[dto.ArtifactTransferStatusResponse](t, statusRecorder)
if status.Completed || status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 {
t.Fatalf("artifact state should remain independent after heartbeat/job/log calls, got %+v", status)
}
stream := getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-channel-isolation")
if stream.LatestSeq != 1 {
t.Fatalf("expected log stream latest seq updated independently, got %+v", stream)
}
job := getJSON[dto.JobResponse](t, router, "/api/v1/jobs/job-1")
if job.State != domain.JobStateSucceeded {
t.Fatalf("expected job terminal state preserved, got %+v", job)
}
}
func TestLightweightRunRoutesRejectHeavyChannelPayloads(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
createOnlyLogStreamForChannelIsolation(t, router)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
ID: "job-heavy-payload",
ServerInstanceID: "server-1",
RunEndpointID: "run-local",
Capability: "process.start",
IdempotencyKey: "heavy-payload-job",
})
claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{"process.start"},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, claimRecorder, http.StatusOK)
claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder)
for _, tc := range []struct {
name string
path string
body string
}{
{
name: "heartbeat rejects artifact chunk fields",
path: "/api/v1/run/control/heartbeat",
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"version":"0.1.1","status":"online","capabilityFingerprint":"cap-jobs","capacity":{"maxJobs":4},"payload":"AAAA","transferId":"transfer-1","hostPath":"/Users/tasia/server"}`, hello.SessionToken),
},
{
name: "job result rejects inline logs and sockets",
path: "/api/v1/run/jobs/result",
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"jobId":%q,"leaseToken":%q,"attempt":%d,"state":"succeeded","progress":{"percent":100},"resultRef":"artifact://jobs/job-heavy-payload/result","entries":[{"seq":1,"line":"log"}],"directSocket":"unix:///tmp/run.sock"}`, hello.SessionToken, claim.Job.JobID, claim.Job.LeaseToken, claim.Job.Attempt),
},
{
name: "log ingest rejects artifact transfer payload",
path: "/api/v1/run/logs/batches",
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"logStreamId":"log-channel-isolation","serverInstanceId":"server-1","streamKey":"stdout","source":"process","firstSeq":1,"lastSeq":1,"compression":"none","checksum":"sha256:bad","entries":[{"seq":1,"timestamp":"2026-07-03T12:00:01Z","line":"line"}],"payload":"AAAA","transferId":"transfer-1"}`, hello.SessionToken),
},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := performRaw(t, router, http.MethodPost, tc.path, tc.body)
assertErrorResponse(t, recorder, http.StatusBadRequest, errorCodeBadRequest)
})
}
}
func createLogStreamForChannelIsolation(t *testing.T, router http.Handler) {
t.Helper()
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-1",
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: "SCUM #1",
}, adminSession)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-channel-isolation",
ServerInstanceID: "server-1",
Source: domain.LogStreamSourceProcess,
StreamKey: "stdout",
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
})
}
func createOnlyLogStreamForChannelIsolation(t *testing.T, router http.Handler) {
t.Helper()
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-channel-isolation",
ServerInstanceID: "server-1",
Source: domain.LogStreamSourceProcess,
StreamKey: "stdout",
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
})
}
func artifactOpenForChannelIsolation(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest {
return dto.ArtifactTransferOpenRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
ArtifactID: "artifact-channel-isolation",
Direction: domain.ArtifactTransferDirectionUpload,
OwnerKind: domain.ArtifactOwnerKindJob,
OwnerID: "job-1",
SizeBytes: int64(len(payload)),
ChunkSizeBytes: chunkSize,
Checksum: validator.BytesChecksum(payload),
IdempotencyKey: "artifact-channel-isolation",
}
}
func artifactChunkForChannelIsolation(sessionToken string, transferID string, payload []byte, index int, chunkSize int) dto.ArtifactChunkUploadRequest {
offset := index * chunkSize
end := offset + chunkSize
if end > len(payload) {
end = len(payload)
}
part := payload[offset:end]
return dto.ArtifactChunkUploadRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
TransferID: transferID,
ArtifactID: "artifact-channel-isolation",
ChunkIndex: index,
Offset: int64(offset),
SizeBytes: len(part),
Checksum: validator.BytesChecksum(part),
Payload: part,
}
}
func TestRunChannelAPIHeavyPayloadRejectionsDoNotMutateState(t *testing.T) {
router := newTestRouter()
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest()))
recorder := performRaw(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"version":"0.1.1","status":"online","capabilityFingerprint":"cap-jobs","capacity":{"maxJobs":4,"runningJobs":1},"payload":"AAAA"}`, hello.SessionToken))
assertErrorResponse(t, recorder, http.StatusBadRequest, errorCodeBadRequest)
endpoint := getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local")
if endpoint.Capacity.RunningJobs != 0 || strings.Contains(endpoint.Capacity.Summary, "AAAA") {
t.Fatalf("rejected heartbeat must not mutate endpoint capacity or store heavy payload, got %+v", endpoint)
}
}
+113
View File
@@ -0,0 +1,113 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
func TestRunControlAPIHelloHeartbeatWorkflow(t *testing.T) {
router := newTestRouter()
helloRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", validRunControlHelloRequest())
assertStatus(t, helloRecorder, http.StatusOK)
hello := decodeBody[dto.RunControlHelloResponse](t, helloRecorder)
if !hello.Accepted || hello.SessionToken == "" || hello.RunEndpointID != "run-local" {
t.Fatalf("expected accepted hello response, got %+v", hello)
}
endpoint := getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local")
if endpoint.Status != domain.RunEndpointStatusOnline || len(endpoint.Capabilities) != 2 {
t.Fatalf("expected registered endpoint metadata, got %+v", endpoint)
}
heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v2",
Capacity: dto.RunCapacityResponse{
MaxJobs: 4,
RunningJobs: 1,
},
})
assertStatus(t, heartbeatRecorder, http.StatusOK)
heartbeat := decodeBody[dto.RunControlHeartbeatResponse](t, heartbeatRecorder)
if !heartbeat.Accepted || !heartbeat.RefreshCapabilities || heartbeat.NextHeartbeatSeconds <= 0 {
t.Fatalf("expected accepted heartbeat with refresh, got %+v", heartbeat)
}
}
func TestRunControlAPIReRegistrationRotatesToken(t *testing.T) {
router := newTestRouter()
first := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
request := validRunControlHelloRequest()
request.Version = "0.2.0"
second := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, request))
if second.SessionToken == first.SessionToken {
t.Fatalf("expected new session token after re-registration, got %q", second.SessionToken)
}
}
func TestRunControlAPIErrors(t *testing.T) {
router := newTestRouter()
invalid := validRunControlHelloRequest()
invalid.RegistrationToken = ""
invalid.Capacity.RunningJobs = 8
invalid.Capacity.MaxJobs = 4
invalidHello := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", invalid)
assertErrorResponse(t, invalidHello, http.StatusBadRequest, errorCodeValidation)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
invalidHeartbeat := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: "stale-token",
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v1",
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertErrorResponse(t, invalidHeartbeat, http.StatusBadRequest, errorCodeValidation)
validHeartbeat := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v1",
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, validHeartbeat, http.StatusOK)
methodFailure := performRaw(t, router, http.MethodGet, "/api/v1/run/control/hello", "")
assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed)
}
func performRunControlHello(t *testing.T, router http.Handler, request dto.RunControlHelloRequest) *httptest.ResponseRecorder {
t.Helper()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", request)
assertStatus(t, recorder, http.StatusOK)
return recorder
}
func validRunControlHelloRequest() dto.RunControlHelloRequest {
return dto.RunControlHelloRequest{
RegistrationToken: "registration-token",
RunEndpointID: "run-local",
DisplayName: "Local Run",
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
Platform: "darwin/arm64",
CapabilityReport: dto.RunCapabilityReport{
Capabilities: []string{"control.hello", "control.heartbeat"},
Fingerprint: "cap-v1",
},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}
}
+32
View File
@@ -0,0 +1,32 @@
package api
import (
"encoding/json"
"net/http"
"time"
"browser.local/platform/dto"
)
const serviceVersion = "0.1.0-dev"
// HealthHandler godoc
// @Summary Platform health
// @Description Returns process health for local development smoke tests.
// @Tags health
// @Success 200 {object} dto.HealthResponse
// @Router /healthz [get]
func HealthHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(dto.HealthResponse{
Service: "platform",
Status: "ok",
Version: serviceVersion,
Time: time.Now().UTC().Format(time.RFC3339),
})
}
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"browser.local/platform/dto"
)
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var body dto.HealthResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Service != "platform" || body.Status != "ok" || body.Version == "" || body.Time == "" {
t.Fatalf("unexpected health body: %+v", body)
}
}
func TestHealthHandlerRejectsUnsupportedMethods(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/healthz", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
+160
View File
@@ -0,0 +1,160 @@
package api
import (
"net/http"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
func TestRunJobChannelAPIWorkflow(t *testing.T) {
router := newTestRouter()
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest()))
heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-jobs",
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, heartbeatRecorder, http.StatusOK)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
ID: "job-1",
RunEndpointID: "run-local",
Capability: "process.start",
IdempotencyKey: "idem-1",
})
claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{"process.start"},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, claimRecorder, http.StatusOK)
claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder)
if !claim.HasJob || claim.Job.JobID != "job-1" || claim.Job.State != domain.JobStateAccepted {
t.Fatalf("expected claimed accepted job, got %+v", claim)
}
ack := postRunJobAck(t, router, dto.RunJobAckRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Message: "started",
})
if ack.Job.State != domain.JobStateRunning {
t.Fatalf("expected running ack, got %+v", ack)
}
progressRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/progress", dto.RunJobProgressRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Progress: dto.JobProgressBody{Percent: 60, Message: "working"},
})
assertStatus(t, progressRecorder, http.StatusOK)
progress := decodeBody[dto.RunJobProgressResponse](t, progressRecorder)
if progress.Job.Progress.Percent != 60 {
t.Fatalf("expected progress update, got %+v", progress)
}
cancelRecorder := performJSON(t, router, http.MethodPost, "/api/v1/jobs/job-1/cancel", dto.RunJobCancelRequestBody{Reason: "operator requested"})
assertStatus(t, cancelRecorder, http.StatusOK)
cancel := decodeBody[dto.RunJobCancelRequestResponse](t, cancelRecorder)
if !cancel.Accepted || cancel.Reason != "operator requested" {
t.Fatalf("expected cancel request, got %+v", cancel)
}
pollRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/cancel", dto.RunJobCancelPollRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
})
assertStatus(t, pollRecorder, http.StatusOK)
poll := decodeBody[dto.RunJobCancelPollResponse](t, pollRecorder)
if !poll.HasCancel || poll.JobID != "job-1" {
t.Fatalf("expected cancel poll result, got %+v", poll)
}
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateCancelled,
Progress: dto.JobProgressBody{Percent: 100, Message: "cancelled"},
Message: "cancelled",
})
assertStatus(t, resultRecorder, http.StatusOK)
result := decodeBody[dto.RunJobResultResponse](t, resultRecorder)
if result.Job.State != domain.JobStateCancelled {
t.Fatalf("expected cancelled result, got %+v", result)
}
reconcileRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/reconcile", dto.RunJobReconcileRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
ActiveJobIDs: []string{"local-only"},
})
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" {
t.Fatalf("expected no active platform jobs and one unknown local job, got %+v", reconcile)
}
}
func TestRunJobChannelAPIErrors(t *testing.T) {
router := newTestRouter()
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest()))
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-1"})
invalidClaim := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: "stale",
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertErrorResponse(t, invalidClaim, http.StatusBadRequest, errorCodeValidation)
claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}))
invalidProgress := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/progress", dto.RunJobProgressRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Progress: dto.JobProgressBody{Percent: 101},
})
assertErrorResponse(t, invalidProgress, http.StatusBadRequest, errorCodeValidation)
badMethod := performRaw(t, router, http.MethodGet, "/api/v1/run/jobs/claim", "")
assertErrorResponse(t, badMethod, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed)
}
func postRunJobAck(t *testing.T, router http.Handler, request dto.RunJobAckRequest) dto.RunJobAckResponse {
t.Helper()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/ack", request)
assertStatus(t, recorder, http.StatusOK)
return decodeBody[dto.RunJobAckResponse](t, recorder)
}
func validRunJobControlHelloRequest() dto.RunControlHelloRequest {
request := validRunControlHelloRequest()
request.CapabilityReport.Capabilities = append(request.CapabilityReport.Capabilities, "process.start")
request.CapabilityReport.Fingerprint = "cap-jobs"
return request
}
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"browser.local/platform/dto"
"browser.local/platform/repo"
"browser.local/platform/service"
"browser.local/platform/validator"
)
const (
errorCodeForbidden = "forbidden"
errorCodeBadRequest = "bad_request"
errorCodeDuplicate = "duplicate_resource"
errorCodeInternal = "internal_error"
errorCodeMethodNotAllowed = "method_not_allowed"
errorCodeNotFound = "not_found"
errorCodeUnauthorized = "unauthorized"
errorCodeValidation = "validation_failed"
)
func decodeJSON[T any](r *http.Request) (T, error) {
var value T
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, fmt.Errorf("decode json: %w", err)
}
var extra struct{}
if err := decoder.Decode(&extra); err != io.EOF {
return value, errors.New("decode json: multiple JSON values are not allowed")
}
return value, nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeAPIError(w http.ResponseWriter, status int, code string, message string, details []string) {
writeJSON(w, status, dto.ErrorResponse{
Code: code,
Message: message,
Details: details,
})
}
func writeMethodNotAllowed(w http.ResponseWriter, allow string) {
w.Header().Set("Allow", allow)
writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil)
}
func writeServiceError(w http.ResponseWriter, err error) {
var validationErr validator.ValidationError
switch {
case errors.As(err, &validationErr):
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", validationErr.Violations)
case errors.Is(err, service.ErrUnauthorized):
writeAPIError(w, http.StatusUnauthorized, errorCodeUnauthorized, "authentication required", nil)
case errors.Is(err, service.ErrForbidden):
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, "account is not allowed to access this resource", nil)
case errors.Is(err, repo.ErrDuplicate):
writeAPIError(w, http.StatusConflict, errorCodeDuplicate, "resource already exists", nil)
case errors.Is(err, repo.ErrNotFound):
writeAPIError(w, http.StatusNotFound, errorCodeNotFound, "resource not found", nil)
default:
writeAPIError(w, http.StatusInternalServerError, errorCodeInternal, "internal server error", nil)
}
}
func writeDecodeError(w http.ResponseWriter, err error) {
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid JSON request body", []string{err.Error()})
}
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"net/http"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestLogIngestAPIWorkflow(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
batch := validLogBatchRequest(t, hello.SessionToken, 1, 2)
ackRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch)
assertStatus(t, ackRecorder, http.StatusOK)
ack := decodeBody[dto.LogBatchIngestResponse](t, ackRecorder)
if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 2 || ack.LatestSeq != 2 {
t.Fatalf("unexpected ack: %+v", ack)
}
stream := getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1")
if stream.LatestSeq != 2 {
t.Fatalf("expected latest seq update, got %+v", stream)
}
queryRecorder := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: "log-1", AfterSeq: 0, Limit: 1})
assertStatus(t, queryRecorder, http.StatusOK)
query := decodeBody[dto.LogStreamCursorResponse](t, queryRecorder)
if len(query.Entries) != 1 || query.Entries[0].Seq != 1 || query.NextSeq != 1 || query.LatestSeq != 2 {
t.Fatalf("unexpected query: %+v", query)
}
}
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
batch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
first := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch)
assertStatus(t, first, http.StatusOK)
duplicate := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch)
assertStatus(t, duplicate, http.StatusOK)
duplicateAck := decodeBody[dto.LogBatchIngestResponse](t, duplicate)
if !duplicateAck.Duplicate {
t.Fatalf("expected duplicate ack, got %+v", duplicateAck)
}
gap := validLogBatchRequest(t, hello.SessionToken, 3, 3)
gapRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", gap)
assertErrorResponse(t, gapRecorder, http.StatusBadRequest, errorCodeValidation)
missingQuery := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: "missing", Limit: 1})
assertErrorResponse(t, missingQuery, http.StatusNotFound, errorCodeNotFound)
}
func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunControlHelloResponse {
t.Helper()
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.install", "process.start", "process.stop", "logs.read")
helloRequest.CapabilityReport.Fingerprint = "cap-logs"
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1"}, adminSession)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-1",
ServerInstanceID: "server-1",
Source: domain.LogStreamSourceProcess,
StreamKey: "stdout",
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
})
return hello
}
func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest {
t.Helper()
entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1)
domainEntries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1)
for seq := firstSeq; seq <= lastSeq; seq++ {
entry := dto.LogEntryBody{Seq: seq, Timestamp: time.Date(2026, 7, 3, 12, 0, int(seq), 0, time.UTC), Level: "info", Line: "line"}
entries = append(entries, entry)
domainEntries = append(domainEntries, domain.LogEntry{Seq: entry.Seq, Timestamp: entry.Timestamp, Level: entry.Level, Line: entry.Line})
}
checksum, err := validator.LogEntriesChecksum(domainEntries)
if err != nil {
t.Fatalf("checksum entries: %v", err)
}
return dto.LogBatchIngestRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
LogStreamID: "log-1",
ServerInstanceID: "server-1",
StreamKey: "stdout",
Source: domain.LogStreamSourceProcess,
FirstSeq: firstSeq,
LastSeq: lastSeq,
Compression: "none",
Checksum: checksum,
Entries: entries,
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"fmt"
"net/http"
"strings"
"browser.local/platform/config"
"browser.local/platform/repo"
"browser.local/platform/service"
)
func NewRouter() http.Handler {
router, err := NewRouterFromConfig(config.Load())
if err != nil {
panic(err)
}
return router
}
func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
store, err := storeFromConfig(cfg)
if err != nil {
return nil, err
}
logStore, err := logStoreFromConfig(cfg)
if err != nil {
return nil, err
}
core := service.NewCoreServiceWithLogStore(store, logStore)
if err := core.SeedLocalPlatformAdmin(); err != nil {
return nil, err
}
return NewRouterWithCore(core), nil
}
func NewRouterWithCore(core service.Core) http.Handler {
handlers := newCoreHandlers(core)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", HealthHandler)
handlers.register(mux)
return mux
}
func storeFromConfig(cfg config.Config) (repo.Store, error) {
switch strings.ToLower(strings.TrimSpace(cfg.StorageBackend)) {
case "", "file":
return repo.NewFileStore(cfg.MetadataPath)
case "memory":
return repo.NewMemoryStore(), nil
case "mysql":
return repo.NewMySQLStore(cfg.MySQLDSN)
default:
return nil, fmt.Errorf("unsupported platform storage backend %q", cfg.StorageBackend)
}
}
func logStoreFromConfig(cfg config.Config) (service.LogBodyStore, error) {
backend := strings.ToLower(strings.TrimSpace(cfg.LogBodyBackend))
if backend == "" {
backend = strings.ToLower(strings.TrimSpace(cfg.StorageBackend))
if backend == "mysql" {
backend = "file"
}
}
switch backend {
case "", "file":
return service.NewFileLogBodyStore(cfg.LogDir)
case "memory":
return service.NewMemoryLogBodyStore(), nil
default:
return nil, fmt.Errorf("unsupported platform log body backend %q", backend)
}
}
+201
View File
@@ -0,0 +1,201 @@
# Platform API Route Catalog
Route declarations and handler comments live in `platform/api`. Request, response, list, and error contracts live in `platform/dto`; handlers must call `platform/service.Core` rather than repositories directly.
## Implemented Core Resource Routes
All routes use JSON request and response bodies. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`.
| Resource | Collection | Detail | DTO contracts |
| --- | --- | --- | --- |
| Users | `GET /api/v1/users`, `POST /api/v1/users` | `GET /api/v1/users/{id}`, `PUT /api/v1/users/{id}` | `UserCreateRequest`, `UserUpdateRequest`, `UserResponse`, `UserListResponse` |
| AI providers | `GET /api/v1/ai-providers`, `POST /api/v1/ai-providers`, `POST /api/v1/ai/invocations`, `POST /api/v1/ai/config-suggestions` | `GET /api/v1/ai-providers/{id}`, `PUT /api/v1/ai-providers/{id}` | `AIProviderCreateRequest`, `AIProviderUpdateRequest`, redacted `AIProviderResponse`, `AIProviderListResponse`, `AIInvocationRequest`, `AIInvocationResponse`, `LlmConfigSuggestionRequest`, `LlmConfigSuggestionResponse` |
| Game plugins | `GET /api/v1/game-plugins`, `POST /api/v1/game-plugins` | `GET /api/v1/game-plugins/{id}` | `GamePluginCreateRequest`, `GamePluginResponse`, `GamePluginListResponse` |
| 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}` | `ServerInstanceCreateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
| 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` |
| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` |
| Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` |
| Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` |
| Artifacts | `GET /api/v1/artifacts`, `POST /api/v1/artifacts` | `GET /api/v1/artifacts/{id}`, `POST /api/v1/artifacts/{id}/download`, `GET /api/v1/artifacts/{id}/content` | `ArtifactCreateRequest`, `ArtifactResponse`, `ArtifactListResponse`, `ArtifactDownloadReferenceResponse`, `ArtifactContentRequest` |
| Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` |
| Audit events | `GET /api/v1/audit-events`, `POST /api/v1/audit-events` | `GET /api/v1/audit-events/{id}` | `AuditEventCreateRequest`, `AuditEventResponse`, `AuditEventListResponse` |
## Implemented Query Filters
- `GET /api/v1/users?status=active`
- `GET /api/v1/ai-providers?kind=openai&status=active`
- `GET /api/v1/game-plugins?serverType=scum&status=installed`
- `GET /api/v1/plugin-marketplace/plugins?serverType=scum&status=installed&capability=logs.read&keyword=scum`
- `GET /api/v1/server-instances?pluginId=server.scum&runEndpointId=run-local&state=draft`
- `GET /api/v1/metrics/server-instances`
- `GET /api/v1/run/endpoints?status=online`
- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued`
- `GET /api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading`
- `GET /api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout`
- `GET /api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success`
## 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/logout`: invalidate the active bearer session token and return `204`.
- `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`.
## Implemented Role-Scoped Server Access
- User-facing server instance list, detail, create, and lifecycle routes require a bearer session.
- Platform administrators can view and manage all server instances.
- Server owners and server administrators can only view and manage server instances they own or administer.
- Server instance responses include bounded `ownerUserId` and `adminUserIds` membership metadata.
- `GET /api/v1/server-instances/{id}/administrators/candidates`: lets the server owner list active non-platform-admin users that can be invited.
- `POST /api/v1/server-instances/{id}/administrators`: lets the server owner invite an active non-platform-admin user using `ServerMemberRequest`.
- `DELETE /api/v1/server-instances/{id}/administrators/{userId}`: lets the server owner remove a server-scoped administrator. The route never deletes the user account.
Server owner membership actions hide and reject platform administrators. Server administrators cannot invite or remove administrators unless they also own the target server.
## Implemented Observability And Config Read Actions
- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators.
- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user.
- `GET /api/v1/server-instances/{id}/config`: returns logical server config content, format, key, config version, and update timestamp for an authorized server instance.
Observability and config read responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
## Implemented Config Write And File Dispatch Actions
- `POST /api/v1/server-instances/{id}/config/diff`: accepts `ServerConfigDiffPreviewRequest`, validates server access, expected config version, logical config key, bounded proposed content, and returns a platform-computed `ServerConfigDiffPreviewResponse` without creating a run job.
- `POST /api/v1/server-instances/{id}/config/approve`: accepts `ServerConfigWriteApprovalRequest`, revalidates the reviewed diff, rejects stale/no-change/unsafe writes, and queues a scoped `config.write` job using `ServerConfigWriteDispatchResponse`.
- `POST /api/v1/file-operations/dispatch`: accepts `FileOperationDispatchRequest`, validates server visibility plus optional plugin permissions, rejects unsafe targets, and queues `files.read` or `files.write` jobs using logical keys and refs.
Config write and file dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents.
## Implemented AI Provider Management Actions
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
- `POST /api/v1/ai-providers/{id}/test`: run local metadata validation using `AIProviderTestResponse`; this does not call external AI services.
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
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.
## 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`.
Plugin registry responses include identity, description, version, server type/display metadata, manifest and create-form schema references, required run capabilities, declared scoped permissions, aggregate platform permissions, lifecycle action references, plugin pages, tags, AI purposes, validation violations for invalid records, and install status. They do not expose raw host paths, raw credentials, direct run sockets, or raw AI provider keys.
## Implemented Plugin Marketplace Actions
- `GET /api/v1/plugin-marketplace/plugins`: list bounded marketplace plugin summaries projected from installed registry metadata. Optional filters include `status`, `serverType`, `capability`, and `keyword`.
- `GET /api/v1/plugin-marketplace/plugins/{id}`: return one marketplace plugin detail using manifest-backed registry metadata.
- `POST /api/v1/plugin-marketplace/plugins/{id}/state`: accept `MarketplacePluginStateRequest` with `install`, `enable`, or `disable` and update registry install state only.
Marketplace responses include game management plugin identity, version, display metadata, server type, installed state, capabilities, pages, permissions, tags, AI purposes, and validation violations. They are a platform registry projection, not a commerce catalog, and they do not include billing, pricing, ratings, reviews, cloud host sales, raw credentials, host paths, direct run sockets, package bytes, or raw AI provider keys.
Marketplace state actions are metadata-only in this change. `install` and `enable` mark the registered plugin `installed`; `disable` marks it `disabled`. These actions do not download external packages, create run jobs, execute plugin code, write files, or contact external services.
## Implemented Plugin Bridge Actions
- `POST /api/v1/plugin-bridge/authorize`: accepts `PluginBridgeAuthorizeRequest` and returns whether an installed plugin page may use one declared bridge action with the effective route permissions.
- `POST /api/v1/plugin-bridge/execute`: accepts `PluginBridgeExecuteRequest`, repeats backend validation and authorization, and executes only mapped platform-mediated actions. Supported execution currently includes server context reads, lifecycle job dispatch for declared run capabilities, log cursor metadata queries, scoped file dispatch, artifact open references, and platform-mediated AI invocation.
Bridge execution responses are typed envelopes with `requestId`, plugin/page/server scope, action, status, result refs, and safe error codes. They do not expose bearer tokens, run sockets, host filesystem paths, raw credentials, storage backend credentials, provider base URLs, raw AI provider keys, or unbounded file/log contents.
Artifact bridge execution returns safe metadata and platform content routes only. It does not return artifact bytes through the bridge message and does not expose run endpoints, storage adapter paths, presigned backend URLs, host paths, or credentials.
## Implemented Server Lifecycle Actions
- `POST /api/v1/server-instances/workflows/create`: accept `ServerLifecycleCreateRequest`, validate plugin/run dependencies and idempotency, create an `installing` server instance, and queue a `process.install` job using `ServerLifecycleResponse`.
- `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`.
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
## 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.
- `POST /api/v1/run/control/heartbeat`: accept `RunControlHeartbeatRequest`, require the active session token, update heartbeat metadata, and return `RunControlHeartbeatResponse` with the next heartbeat hint and optional capability refresh request.
Run control actions carry only lightweight metadata: endpoint ID, display name, version, status, capability fingerprint/list, capacity, session token, and timing hints. They do not carry job bodies, logs, artifact chunks, host paths, raw credentials, or direct sockets.
Control is the highest-priority run-facing channel; artifact/file transfer pressure must not delay heartbeat processing or mutate endpoint capacity through heavy payload fields.
## 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.
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.
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
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and audit trails. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
## Implemented Run Artifact Actions
- `POST /api/v1/run/artifacts/open`: accept `ArtifactTransferOpenRequest`, validate active run session and scoped artifact owner, create or reuse uploading artifact metadata, and return `ArtifactTransferOpenResponse` with transfer resume state.
- `POST /api/v1/run/artifacts/chunks`: accept `ArtifactChunkUploadRequest`, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes.
- `POST /api/v1/run/artifacts/status`: accept `ArtifactTransferStatusRequest` and return `ArtifactTransferStatusResponse` with received chunks and next missing chunk index.
- `POST /api/v1/run/artifacts/complete`: accept `ArtifactTransferCompleteRequest`, verify all chunks and final checksum, mark the artifact available, and return `ArtifactTransferCompleteResponse`.
Run artifact actions carry bounded upload metadata and chunk payloads only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and JSON chunk payload bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials.
Artifact/file transfer is lower priority than control, job lifecycle metadata, and durable log ingest. Slow or retrying chunks must not block heartbeat, job ack/result delivery, cancellation/reconcile calls, or log batch acknowledgement; lightweight routes reject heavy transfer payloads rather than storing them.
## Implemented Browser Artifact Download Actions
- `GET /api/v1/artifacts/{id}`: returns authorized artifact metadata for the current bearer session.
- `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.
## Error Contract
API errors use `dto.ErrorResponse`:
- `400`: malformed JSON or validation failure.
- `401`: missing or invalid bearer session token.
- `403`: valid credentials for an account that is pending, disabled, or otherwise forbidden.
- `404`: missing resource or missing dependency reported by the service layer.
- `409`: duplicate resource ID.
- `405`: unsupported method on an implemented route.
- `500`: unexpected platform error.
## Deferred Route Groups
These route groups remain documented future work beyond the currently implemented routes:
- 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.
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
- Live AI provider connectivity tests and remote model discovery.
- Server restart/update/delete routes.
## Core Service Boundary
- `platform/service.Core` owns create/list/get workflows and cross-resource invariants.
- `platform/repo.Store` owns repository access and currently has a durable file-backed implementation for local startup plus an in-memory implementation for tests.
- `platform/validator` owns local resource validation and dependency compatibility checks.
- Handlers must never expose run credentials, host paths, or raw AI provider keys.
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// serverInstanceCreateWorkflow godoc
// @Summary Create server instance workflow
// @Description Creates a server instance through the platform-mediated lifecycle workflow and queues an install job for the selected run endpoint.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param body body dto.ServerLifecycleCreateRequest true "Server lifecycle create request"
// @Success 200 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/workflows/create [post]
func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerLifecycleCreateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.CreateServerInstanceWorkflowForSession(bearerToken(r), request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}
// serverInstanceStart godoc
// @Summary Start server instance
// @Description Validates lifecycle state and config version, then queues a start job through the platform job channel.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request"
// @Success 200 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/start [post]
func (h *coreHandlers) serverInstanceStart(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.StartServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}
// serverInstanceStop godoc
// @Summary Stop server instance
// @Description Validates lifecycle state and config version, then queues a stop job through the platform job channel.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request"
// @Success 200 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/stop [post]
func (h *coreHandlers) serverInstanceStop(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.StopServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}