功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
+32 -4
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
@@ -45,7 +46,8 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
if err := validator.ValidateAIInvocationRequest(request); err != nil {
return domain.AIInvocationResponse{}, err
}
if _, err := svc.GetCurrentUser(sessionID); err != nil {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.AIInvocationResponse{}, err
}
if request.ServerInstanceID != "" {
@@ -56,6 +58,13 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
if request.PluginID != "" && instance.PluginID != request.PluginID {
return safeAIDenial(request, "plugin scope does not match server instance"), nil
}
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
config, err := svc.GetServerConfigForSession(sessionID, instance.ID)
if err != nil {
return domain.AIInvocationResponse{}, err
}
request.CurrentConfig = config.Content
}
}
if request.PluginID != "" {
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
@@ -82,14 +91,24 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
}
result, err := svc.aiProviderClient.Invoke(provider, request)
if err != nil {
auditID, auditErr := svc.recordAuditEventWithID(user.ID, "ai.provider.invoke.failed", "ai-provider", provider.ID, domain.AuditResultFailed, "AI provider invocation failed safely")
if auditErr != nil {
return domain.AIInvocationResponse{}, auditErr
}
svc.productionMu.Lock()
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider invocation failed", Message: "AI provider invocation failed safely", Retryable: false, LastAuditEventID: auditID})
svc.productionMu.Unlock()
if alertErr != nil {
return domain.AIInvocationResponse{}, alertErr
}
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
RequestID: request.RequestID,
Purpose: request.Purpose,
ProviderID: provider.ID,
Model: safeModel(request.Model, provider),
Status: "error",
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider), Mocked: true},
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: safeBridgeReason(err.Error())},
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider)},
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: "AI provider invocation failed safely"},
}), nil
}
response := domain.AIInvocationResponse{
@@ -102,7 +121,16 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
Usage: result.Usage,
}
if result.SuggestedConfig != "" {
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: "server.properties", SuggestedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch"}
if request.ServerInstanceID == "" {
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
}
svc.productionMu.Lock()
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
svc.productionMu.Unlock()
if persistErr != nil {
return domain.AIInvocationResponse{}, persistErr
}
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: preview.Key, SuggestedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, DiffID: preview.ID, ExpiresAt: preview.ExpiresAt.Format(time.RFC3339)}
}
if err := validator.ValidateAIInvocationResponse(response); err != nil {
return domain.AIInvocationResponse{}, err
+325
View File
@@ -0,0 +1,325 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"browser.local/platform/domain"
)
const maxAIProviderResponseBytes = 1024 * 1024
type AIProviderSecretResolver interface {
Resolve(reference string) (string, error)
}
type EnvironmentAIProviderSecretResolver struct{}
func (EnvironmentAIProviderSecretResolver) Resolve(reference string) (string, error) {
reference = strings.TrimSpace(reference)
if strings.HasPrefix(reference, "env://") {
name := strings.TrimPrefix(reference, "env://")
if !validEnvironmentName(name) {
return "", errors.New("AI provider secret reference is invalid")
}
if value := os.Getenv(name); value != "" {
return value, nil
}
return "", errors.New("AI provider secret is unavailable")
}
if strings.HasPrefix(reference, "secret://providers/") {
name := strings.TrimPrefix(reference, "secret://providers/")
name = strings.ToUpper(regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(name, "_"))
name = strings.Trim(name, "_")
if name == "" {
return "", errors.New("AI provider secret reference is invalid")
}
if value := os.Getenv("PLATFORM_AI_PROVIDER_" + name + "_API_KEY"); value != "" {
return value, nil
}
return "", errors.New("AI provider secret is unavailable")
}
return "", errors.New("AI provider secret backend is unsupported")
}
type HTTPAIProviderClient struct {
HTTPClient *http.Client
SecretResolver AIProviderSecretResolver
}
type openAIChatRequest struct {
Model string `json:"model"`
Messages []openAIChatMessage `json:"messages"`
Temperature float64 `json:"temperature"`
}
type openAIChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type openAIChatResponse struct {
Choices []struct {
Message openAIChatMessage `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
type claudeRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Messages []openAIChatMessage `json:"messages"`
}
type claudeResponse struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
type geminiRequest struct {
Contents []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"contents"`
}
type geminiResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
} `json:"usageMetadata"`
}
type structuredAIRecommendation struct {
Recommendation string `json:"recommendation"`
SuggestedConfig string `json:"suggestedConfig"`
}
func (client HTTPAIProviderClient) Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
model := safeModel(request.Model, provider)
endpoint, err := providerEndpoint(provider, model)
if err != nil {
return domain.AIProviderInvocationResult{}, err
}
secret := ""
if provider.RelayMode != domain.AIRelayModeLocal {
resolver := client.SecretResolver
if resolver == nil {
resolver = EnvironmentAIProviderSecretResolver{}
}
secret, err = resolver.Resolve(provider.APIKeyRef)
if err != nil {
return domain.AIProviderInvocationResult{}, errors.New("AI provider credential is unavailable")
}
}
prompt := boundedProviderPrompt(request)
body, err := providerRequestBody(provider.Kind, model, prompt)
if err != nil {
return domain.AIProviderInvocationResult{}, err
}
httpRequest, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return domain.AIProviderInvocationResult{}, errors.New("AI provider request could not be created")
}
httpRequest.Header.Set("Content-Type", "application/json")
setProviderAuthorization(httpRequest, provider.Kind, secret)
httpClient := client.HTTPClient
if httpClient == nil {
timeout := time.Duration(provider.TimeoutMS) * time.Millisecond
if timeout <= 0 || timeout > 2*time.Minute {
timeout = 30 * time.Second
}
httpClient = &http.Client{Timeout: timeout}
}
response, err := httpClient.Do(httpRequest)
if err != nil {
return domain.AIProviderInvocationResult{}, errors.New("AI provider transport failed")
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return domain.AIProviderInvocationResult{}, fmt.Errorf("AI provider returned status class %dxx", response.StatusCode/100)
}
payload, err := io.ReadAll(io.LimitReader(response.Body, maxAIProviderResponseBytes+1))
if err != nil || len(payload) > maxAIProviderResponseBytes {
return domain.AIProviderInvocationResult{}, errors.New("AI provider response was invalid or too large")
}
result, err := parseProviderResponse(provider.Kind, payload)
if err != nil {
return domain.AIProviderInvocationResult{}, errors.New("AI provider response was invalid")
}
result.Usage.ProviderID = provider.ID
result.Usage.Model = model
result.Usage.Mocked = false
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
result = parseStructuredRecommendation(result)
}
return result, nil
}
func (svc *CoreService) ConfigureAIProviderMode(mode string) error {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "mock", "test", "local":
svc.aiProviderClient = MockAIProviderClient{}
case "", "live", "http":
svc.aiProviderClient = HTTPAIProviderClient{SecretResolver: EnvironmentAIProviderSecretResolver{}}
default:
return validationError("AI provider mode must be live or mock")
}
return nil
}
func providerEndpoint(provider domain.AIProvider, model string) (string, error) {
base, err := url.Parse(strings.TrimRight(provider.BaseURL, "/"))
if err != nil || base.Host == "" {
return "", errors.New("AI provider endpoint is invalid")
}
host := base.Hostname()
if base.Scheme != "https" && !(base.Scheme == "http" && provider.RelayMode == domain.AIRelayModeLocal && isLoopbackHost(host)) {
return "", errors.New("AI provider endpoint requires HTTPS")
}
switch provider.Kind {
case domain.AIProviderKindClaude:
base.Path = strings.TrimRight(base.Path, "/") + "/messages"
case domain.AIProviderKindGemini:
base.Path = strings.TrimRight(base.Path, "/") + "/models/" + url.PathEscape(model) + ":generateContent"
default:
base.Path = strings.TrimRight(base.Path, "/") + "/chat/completions"
}
base.RawQuery = ""
base.Fragment = ""
return base.String(), nil
}
func providerRequestBody(kind domain.AIProviderKind, model, prompt string) ([]byte, error) {
switch kind {
case domain.AIProviderKindClaude:
return json.Marshal(claudeRequest{Model: model, MaxTokens: 2048, Messages: []openAIChatMessage{{Role: "user", Content: prompt}}})
case domain.AIProviderKindGemini:
body := geminiRequest{}
body.Contents = append(body.Contents, struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
}{Parts: []struct {
Text string `json:"text"`
}{{Text: prompt}}})
return json.Marshal(body)
default:
return json.Marshal(openAIChatRequest{Model: model, Messages: []openAIChatMessage{{Role: "user", Content: prompt}}, Temperature: 0.2})
}
}
func parseProviderResponse(kind domain.AIProviderKind, payload []byte) (domain.AIProviderInvocationResult, error) {
switch kind {
case domain.AIProviderKindClaude:
var response claudeResponse
if err := json.Unmarshal(payload, &response); err != nil || len(response.Content) == 0 || strings.TrimSpace(response.Content[0].Text) == "" {
return domain.AIProviderInvocationResult{}, errors.New("invalid Claude response")
}
return domain.AIProviderInvocationResult{Recommendation: response.Content[0].Text, Usage: domain.AIInvocationUsage{InputTokens: response.Usage.InputTokens, OutputTokens: response.Usage.OutputTokens}}, nil
case domain.AIProviderKindGemini:
var response geminiResponse
if err := json.Unmarshal(payload, &response); err != nil || len(response.Candidates) == 0 || len(response.Candidates[0].Content.Parts) == 0 || strings.TrimSpace(response.Candidates[0].Content.Parts[0].Text) == "" {
return domain.AIProviderInvocationResult{}, errors.New("invalid Gemini response")
}
return domain.AIProviderInvocationResult{Recommendation: response.Candidates[0].Content.Parts[0].Text, Usage: domain.AIInvocationUsage{InputTokens: response.UsageMetadata.PromptTokenCount, OutputTokens: response.UsageMetadata.CandidatesTokenCount}}, nil
default:
var response openAIChatResponse
if err := json.Unmarshal(payload, &response); err != nil || len(response.Choices) == 0 || strings.TrimSpace(response.Choices[0].Message.Content) == "" {
return domain.AIProviderInvocationResult{}, errors.New("invalid chat completion response")
}
return domain.AIProviderInvocationResult{Recommendation: response.Choices[0].Message.Content, Usage: domain.AIInvocationUsage{InputTokens: response.Usage.PromptTokens, OutputTokens: response.Usage.CompletionTokens}}, nil
}
}
func parseStructuredRecommendation(result domain.AIProviderInvocationResult) domain.AIProviderInvocationResult {
raw := strings.TrimSpace(result.Recommendation)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
var structured structuredAIRecommendation
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &structured) == nil && strings.TrimSpace(structured.SuggestedConfig) != "" {
result.Recommendation = strings.TrimSpace(structured.Recommendation)
result.SuggestedConfig = structured.SuggestedConfig
}
return result
}
func boundedProviderPrompt(request domain.AIInvocationRequest) string {
var builder strings.Builder
builder.WriteString("Purpose: ")
builder.WriteString(request.Purpose)
builder.WriteString("\nRequest: ")
builder.WriteString(request.Prompt)
if request.CurrentConfig != "" {
builder.WriteString("\nCurrent configuration:\n")
builder.WriteString(request.CurrentConfig)
}
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes require separate operator approval.")
}
return builder.String()
}
func setProviderAuthorization(request *http.Request, kind domain.AIProviderKind, secret string) {
if secret == "" {
return
}
switch kind {
case domain.AIProviderKindClaude:
request.Header.Set("x-api-key", secret)
request.Header.Set("anthropic-version", "2023-06-01")
case domain.AIProviderKindGemini:
request.Header.Set("x-goog-api-key", secret)
default:
request.Header.Set("Authorization", "Bearer "+secret)
}
}
func validEnvironmentName(value string) bool {
if value == "" {
return false
}
for _, char := range value {
if (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' {
continue
}
return false
}
return true
}
func isLoopbackHost(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
+55
View File
@@ -0,0 +1,55 @@
package service
import (
"io"
"net/http"
"strings"
"testing"
"browser.local/platform/domain"
)
func TestHTTPAIProviderClientReturnsBoundedStructuredRecommendation(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("unexpected provider path %q", r.URL.Path)
}
if r.Header.Get("Authorization") != "" {
t.Fatal("local provider must not receive an authorization header")
}
body, _ := io.ReadAll(r.Body)
if strings.Contains(string(body), "apiKeyRef") {
t.Fatal("provider request leaked secret reference metadata")
}
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"{\"recommendation\":\"Review PVP policy\",\"suggestedConfig\":\"pvp=false\\n\"}"}}],"usage":{"prompt_tokens":12,"completion_tokens":8}}`))}, nil
})
client := HTTPAIProviderClient{HTTPClient: &http.Client{Transport: transport}}
result, err := client.Invoke(domain.AIProvider{ID: "local", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:18000/v1", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, DefaultModel: "test-model"}, domain.AIInvocationRequest{RequestID: "http-ai-1", Purpose: "config.suggest", Prompt: "disable pvp", CurrentConfig: "pvp=true\n"})
if err != nil {
t.Fatalf("invoke provider: %v", err)
}
if result.Recommendation != "Review PVP policy" || result.SuggestedConfig != "pvp=false\n" || result.Usage.Mocked {
t.Fatalf("unexpected provider result %+v", result)
}
}
func TestHTTPAIProviderClientRedactsTransportFailure(t *testing.T) {
client := HTTPAIProviderClient{HTTPClient: &http.Client{Transport: failingRoundTripper{}}}
_, err := client.Invoke(domain.AIProvider{ID: "local", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:18000/v1", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, DefaultModel: "test-model"}, domain.AIInvocationRequest{RequestID: "http-ai-fail", Purpose: "logs.diagnose", Prompt: "inspect"})
if err == nil || err.Error() != "AI provider transport failed" {
t.Fatalf("expected redacted transport failure, got %v", err)
}
}
type failingRoundTripper struct{}
func (failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
return nil, io.ErrUnexpectedEOF
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
@@ -0,0 +1,131 @@
package service
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestClientManagerLifecycleInputIncludesGeneratedCompanionConfig(t *testing.T) {
svc, ownerSession, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
capabilities := []string{"component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"}
for index := range plugin.RuntimeProfiles.ClientManagers {
manager := &plugin.RuntimeProfiles.ClientManagers[index]
if manager.Key != "scum-client-manager" {
continue
}
manager.ConfigTemplates = []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}}
manager.Health.IntervalSeconds = 30
manager.Health.RequiredCapabilities = domain.CopyStringSlice(capabilities)
}
plugin.GameClientBridge.Companion = domain.GameClientBridgeCompanionDeclaration{
ProfileKey: "scum-client-manager",
ConfigTemplateKey: "client-config",
ConfigSchemaRef: "schemas/companion/config.schema.json",
ConfigFormat: "yaml",
PlatformBaseURLSource: "run-control",
RegistrationProof: "hmac-sha256",
ProofMaterialSource: "component-package",
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
SessionMode: "component-session",
TLSPolicy: "verify-system-roots",
HeartbeatIntervalSeconds: 30,
CommandPollIntervalSeconds: 5,
RequestTimeoutSeconds: 15,
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update companion declaration: %v", err)
}
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "companion-config-build-v1")
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "companion-config-deploy-v1"})
if err != nil {
t.Fatalf("queue companion deployment: %v", err)
}
runSession := registerClientManagerRun(t, svc)
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
request := domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
input, err := svc.GetClientManagerLifecycleInput(request)
if err != nil {
t.Fatalf("get companion lifecycle input: %v", err)
}
config := input.CompanionConfig
if config == nil {
t.Fatal("expected generated companion config input")
}
if config.SchemaVersion != 1 || config.ConfigTemplateRef != "config.yaml.example" || config.ConfigOutputRef != "config.yaml" || config.ConfigSchemaRef != "schemas/companion/config.schema.json" {
t.Fatalf("unexpected companion template contract: %+v", config)
}
if config.InstallationID != view.Installation.ID || config.ServerInstanceID != instance.ID || config.PluginID != plugin.ID || config.ProfileKey != "scum-client-manager" || config.ArtifactID != distribution.ArtifactID || config.KeyGeneration != distribution.KeyGeneration || config.DeploymentGeneration != view.Installation.DeploymentGeneration {
t.Fatalf("unexpected companion identity fence: %+v", config)
}
if strings.Join(config.Capabilities, ",") != strings.Join(capabilities, ",") || config.PlatformBaseURLSource != "run-control" || config.RegistrationProof != "hmac-sha256" || config.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" || config.SessionMode != "component-session" || config.TLSPolicy != "verify-system-roots" {
t.Fatalf("unexpected companion registration policy: %+v", config)
}
if config.HeartbeatIntervalSeconds != 30 || config.CommandPollIntervalSeconds != 5 || config.RequestTimeoutSeconds != 15 {
t.Fatalf("unexpected companion timing policy: %+v", config)
}
plugin, err = svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("reload plugin: %v", err)
}
plugin.GameClientBridge.Companion.TLSPolicy = "skip-verification"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("persist unsafe companion policy: %v", err)
}
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "security policy") {
t.Fatalf("expected unsafe persisted policy to fail closed, got %v", err)
}
plugin.GameClientBridge.Companion.TLSPolicy = "verify-system-roots"
plugin.GameClientBridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("persist reserved proof environment: %v", err)
}
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "proofMaterialEnv") {
t.Fatalf("expected reserved proof environment to fail closed, got %v", err)
}
plugin.GameClientBridge.Companion.ProofMaterialEnv = "SCUM_COMPONENT_PROOF"
for index := range plugin.RuntimeProfiles.ClientManagers {
plugin.RuntimeProfiles.ClientManagers[index].ConfigTemplates = nil
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("remove config template: %v", err)
}
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "config template") {
t.Fatalf("expected missing template to fail safely, got %v", err)
}
}
func TestClientManagerCompanionConfigInputIsOptionalForOtherProfiles(t *testing.T) {
config, err := clientManagerCompanionConfigInput(
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ProfileKey: "bridge-client"}}},
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
"artifact-1",
"1.0.0",
"revision-1",
)
if err != nil || config != nil {
t.Fatalf("expected no companion config for another profile, config=%+v err=%v", config, err)
}
config, err = clientManagerCompanionConfigInput(
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ConfigFormat: "yaml"}}},
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
"artifact-1",
"1.0.0",
"revision-1",
)
if err == nil || config != nil || !strings.Contains(err.Error(), "incomplete") {
t.Fatalf("expected partial companion declaration to fail closed, config=%+v err=%v", config, err)
}
}
+63 -1
View File
@@ -525,6 +525,64 @@ func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string
return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound
}
func clientManagerCompanionConfigInput(plugin domain.GamePlugin, profile domain.RuntimeClientManagerProfile, installation domain.ClientManagerInstallation, artifactID, version, revision string) (*domain.ClientManagerCompanionConfigInput, error) {
companion := plugin.GameClientBridge.Companion
if companion == (domain.GameClientBridgeCompanionDeclaration{}) {
return nil, nil
}
if companion.ProfileKey == "" {
return nil, validationError("client-manager companion declaration is incomplete")
}
if companion.ProfileKey != installation.ProfileKey {
return nil, nil
}
var configTemplate domain.RuntimeConfigTemplate
found := false
for _, candidate := range profile.ConfigTemplates {
if candidate.Key == companion.ConfigTemplateKey {
configTemplate = candidate
found = true
break
}
}
if !found || strings.TrimSpace(configTemplate.TemplateRef) == "" || configTemplate.OutputRef != "config.yaml" {
return nil, validationError("client-manager companion config template is unavailable")
}
config := domain.ClientManagerCompanionConfigInput{
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
ConfigTemplateKey: companion.ConfigTemplateKey,
ConfigTemplateRef: configTemplate.TemplateRef,
ConfigOutputRef: configTemplate.OutputRef,
ConfigSchemaRef: companion.ConfigSchemaRef,
ConfigFormat: companion.ConfigFormat,
PlatformBaseURLSource: companion.PlatformBaseURLSource,
InstallationID: installation.ID,
ServerInstanceID: installation.ServerInstanceID,
PluginID: plugin.ID,
ProfileKey: installation.ProfileKey,
ArtifactID: artifactID,
Version: version,
SourceRevision: revision,
TargetOS: installation.TargetOS,
TargetArch: installation.TargetArch,
KeyGeneration: installation.KeyGeneration,
DeploymentGeneration: installation.DeploymentGeneration,
Capabilities: domain.CopyStringSlice(profile.Health.RequiredCapabilities),
RegistrationProof: companion.RegistrationProof,
ProofMaterialSource: companion.ProofMaterialSource,
ProofMaterialEnv: companion.ProofMaterialEnv,
SessionMode: companion.SessionMode,
TLSPolicy: companion.TLSPolicy,
HeartbeatIntervalSeconds: companion.HeartbeatIntervalSeconds,
CommandPollIntervalSeconds: companion.CommandPollIntervalSeconds,
RequestTimeoutSeconds: companion.RequestTimeoutSeconds,
}
if err := validator.ValidateClientManagerCompanionConfigInput(config); err != nil {
return nil, err
}
return &config, nil
}
func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool {
for _, target := range profile.SupportedTargets {
if target.OS == targetOS && target.Arch == targetArch {
@@ -755,7 +813,11 @@ func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientMana
revision = installation.ActiveRevision
checksum = ""
}
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey}), nil
companionConfig, err := clientManagerCompanionConfigInput(plugin, profile, installation, artifactID, version, revision)
if err != nil {
return domain.ClientManagerLifecycleInput{}, err
}
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey, CompanionConfig: companionConfig}), nil
}
func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
+9 -1
View File
@@ -23,9 +23,17 @@ type dependencyResolution struct {
}
func (svc *CoreService) GetDependencyCatalogForSession(sessionID, serverInstanceID string) (domain.DependencyCatalog, error) {
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.DependencyCatalog{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.DependencyCatalog{}, err
}
if !pluginDeclares(plugin, "server.dependencies.manage") {
return domain.DependencyCatalog{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
}
resolution, err := svc.resolveDependencyContext(serverInstanceID)
if err != nil {
return domain.DependencyCatalog{}, err
@@ -76,6 +76,29 @@ func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
}
}
func TestDependencyCatalogNamesMissingPluginPermission(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
permissions := plugin.DeclaredPermissions[:0]
for _, permission := range plugin.DeclaredPermissions {
if permission != "server.dependencies.manage" {
permissions = append(permissions, permission)
}
}
plugin.DeclaredPermissions = permissions
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin: %v", err)
}
_, err = svc.GetDependencyCatalogForSession(session, instance.ID)
if !errors.Is(err, ErrForbidden) || !strings.Contains(err.Error(), "server.dependencies.manage") {
t.Fatalf("expected named dependency permission denial, got %v", err)
}
}
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
+6 -5
View File
@@ -455,6 +455,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
}
}
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
dependencyPermissionDeclared := pluginDeclares(plugin, "server.dependencies.manage")
actions := domain.ServerRuntimeActions{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
@@ -468,8 +469,8 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)),
runtimeAction("dependencies-install", "Install dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)),
runtimeAction("dependencies-check", "Check dependencies", dependencyPermissionDeclared && endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason))),
runtimeAction("dependencies-install", "Install dependencies", dependencyPermissionDeclared && endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason))),
runtimeAction("live-logs", "Live logs", pluginSupports(plugin, "logs.read"), "plugin does not declare live logs"),
runtimeAction("historical-logs", "Historical logs", endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason)),
},
@@ -608,7 +609,7 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d
}
if !pluginDeclares(plugin, "server.dependencies.manage") {
_ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: plugin permission is not declared")
return domain.Job{}, ErrForbidden
return domain.Job{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
}
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil {
return domain.Job{}, err
@@ -726,11 +727,11 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
if plugin.Status != domain.GamePluginStatusInstalled {
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
return ErrForbidden
return forbiddenError("plugin is not installed")
}
if !containsString(plugin.DeclaredPermissions, permission) {
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin permission is not declared")
return ErrForbidden
return forbiddenError("plugin does not declare required permission: " + permission)
}
return nil
}
+36
View File
@@ -96,6 +96,42 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
}
}
func TestCoreServiceRuntimeActionsGateDependenciesOnPluginPermission(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
permissions := plugin.DeclaredPermissions[:0]
for _, permission := range plugin.DeclaredPermissions {
if permission != "server.dependencies.manage" {
permissions = append(permissions, permission)
}
}
plugin.DeclaredPermissions = permissions
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin: %v", err)
}
actions, err := svc.GetServerRuntimeActionsForSession(session, instance.ID)
if err != nil {
t.Fatalf("get runtime actions: %v", err)
}
seen := 0
for _, action := range actions.Actions {
if action.Key != "dependencies-check" && action.Key != "dependencies-install" {
continue
}
seen++
if action.Available || action.Reason != "plugin permission is not declared" {
t.Fatalf("expected dependency action gated by plugin permission, got %+v", action)
}
}
if seen != 2 {
t.Fatalf("expected both dependency actions in %+v", actions.Actions)
}
}
func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUpload(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
+642
View File
@@ -0,0 +1,642 @@
package service
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const defaultGameClientBridgeLeaseDuration = 60 * time.Second
const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour
type gameClientBridgeComponentSession struct {
Session domain.ClientManagerSession
Installation domain.ClientManagerInstallation
}
func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if !canAccessServer(user, instance) {
return domain.GameClientBridgeCommand{}, ErrForbidden
}
if instance.PluginID != request.PluginID {
return domain.GameClientBridgeCommand{}, validationError("bridge command plugin must match server instance")
}
return svc.queueGameClientBridgeCommand(user.ID, request)
}
func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverInstanceID string) (domain.GameClientBridgeStatus, error) {
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
return domain.GameClientBridgeStatus{}, err
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return domain.GameClientBridgeStatus{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.GameClientBridgeStatus{}, err
}
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}}
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.GameClientBridgeStatus{}, err
}
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
if !containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
continue
}
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: profile.Key, Reason: "compatible companion session is offline", CommandTypes: gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands), SnapshotTypes: gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots), QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates)}
for _, installation := range installations {
if installation.ProfileKey != profile.Key || (installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded) || installation.RequiresRedeploy {
continue
}
sessions, listErr := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
if listErr != nil {
return domain.GameClientBridgeStatus{}, listErr
}
for _, session := range sessions {
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
declaration.Available = true
declaration.Reason = ""
break
}
}
}
status.Profiles = append(status.Profiles, declaration)
status.Available = status.Available || declaration.Available
}
if len(status.Profiles) > 0 {
status.Reason = "no compatible companion session is online"
}
if status.Available {
status.Reason = ""
}
return domain.CopyGameClientBridgeStatus(status), nil
}
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID)
if err != nil {
return nil, err
}
filter.PluginID = instance.PluginID
commands, err := svc.store.GameClientBridgeCommands().List(filter)
if err != nil {
return nil, err
}
result := make([]domain.GameClientBridgeCommand, len(commands))
for index, command := range commands {
result[index] = domain.CopyGameClientBridgeCommand(command)
}
return result, nil
}
func (svc *CoreService) GetGameClientBridgeCommandForSession(sessionID, commandID string) (domain.GameClientBridgeCommand, error) {
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID string, query domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) {
if err := validator.ValidateGameClientBridgeSnapshotQuery(query); err != nil {
return nil, err
}
if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil {
return nil, err
}
instance, err := svc.store.ServerInstances().Get(query.ServerInstanceID)
if err != nil {
return nil, err
}
if query.PluginID != instance.PluginID {
return nil, ErrForbidden
}
limit := query.Limit
if limit == 0 {
limit = 50
}
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: query.ServerInstanceID, PluginID: query.PluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: limit})
if err != nil {
return nil, err
}
result := make([]domain.GameClientBridgeSnapshot, len(snapshots))
for index, snapshot := range snapshots {
result[index] = domain.CopyGameClientBridgeSnapshot(snapshot)
}
return result, nil
}
func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) {
profileDeclared := false
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
if profile.Key == profileKey && containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
profileDeclared = true
break
}
}
if !profileDeclared {
return domain.GameClientBridgeCommandDeclaration{}, false
}
for _, declaration := range plugin.GameClientBridge.Commands {
if declaration.Type == commandType {
return declaration, true
}
}
return domain.GameClientBridgeCommandDeclaration{}, false
}
func gameClientBridgeCommandTypes(declarations []domain.GameClientBridgeCommandDeclaration) []string {
values := make([]string, len(declarations))
for index, declaration := range declarations {
values[index] = declaration.Type
}
return values
}
func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapshotDeclaration) []string {
seen := map[string]struct{}{}
values := make([]string, 0, len(declarations))
for _, declaration := range declarations {
if _, exists := seen[declaration.Type]; exists {
continue
}
seen[declaration.Type] = struct{}{}
values = append(values, declaration.Type)
}
return values
}
func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
values := make([]string, len(declarations))
for index, declaration := range declarations {
values[index] = declaration.Key
}
return values
}
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
declaration, declared := gameClientBridgeCommandDeclaration(plugin, request.ProfileKey, request.CommandType)
if !declared {
return domain.GameClientBridgeCommand{}, validationError("bridge command type is not declared for the profile")
}
payload, err := json.Marshal(request.Payload)
if err != nil || len(payload) > declaration.MaxPayloadBytes {
return domain.GameClientBridgeCommand{}, validationError("bridge command payload exceeds declaration")
}
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
}
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
if err == nil {
return domain.CopyGameClientBridgeCommand(existing), nil
}
if err != nil && err != repo.ErrNotFound {
return domain.GameClientBridgeCommand{}, err
}
if !request.ExpiresAt.After(stamp) {
return domain.GameClientBridgeCommand{}, validationError("bridge command expiresAt must be in the future")
}
approvalState := domain.GameClientBridgeApprovalNotRequired
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelOperator {
approvalState = domain.GameClientBridgeApprovalApproved
}
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin {
approvalState = domain.GameClientBridgeApprovalPending
if requester, requesterErr := svc.store.Users().Get(requesterID); requesterErr == nil && isPlatformAdmin(requester) {
approvalState = domain.GameClientBridgeApprovalApproved
}
}
svc.bridgeSeq++
command := domain.GameClientBridgeCommand{
ID: fmt.Sprintf("bridge-command-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
ServerInstanceID: request.ServerInstanceID,
PluginID: request.PluginID,
ProfileKey: request.ProfileKey,
CommandType: request.CommandType,
Payload: domain.CopyGameClientBridgePayload(request.Payload),
IdempotencyKey: request.IdempotencyKey,
Priority: request.Priority,
State: domain.GameClientBridgeCommandPending,
ApprovalState: approvalState,
RequesterID: requesterID,
ExpiresAt: request.ExpiresAt,
CreatedAt: stamp,
UpdatedAt: stamp,
}
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, "queued declared game client bridge command")
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.AuditReferences = []string{auditID}
if err := svc.store.GameClientBridgeCommands().Create(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridgeComponentSession, limit int) ([]domain.GameClientBridgeCommand, error) {
if limit == 0 {
limit = 10
}
if limit < 1 || limit > 50 {
return nil, validationError("bridge claim limit must be between 1 and 50")
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
return nil, err
}
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, State: domain.GameClientBridgeCommandPending})
if err != nil {
return nil, err
}
sort.SliceStable(commands, func(left, right int) bool {
if commands[left].Priority != commands[right].Priority {
return commands[left].Priority > commands[right].Priority
}
if !commands[left].CreatedAt.Equal(commands[right].CreatedAt) {
return commands[left].CreatedAt.Before(commands[right].CreatedAt)
}
return commands[left].ID < commands[right].ID
})
claimed := make([]domain.GameClientBridgeCommand, 0, limit)
for _, command := range commands {
if len(claimed) == limit {
break
}
if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved {
continue
}
fencingToken := command.Claim.FencingToken + 1
command.State = domain.GameClientBridgeCommandClaimed
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
command.UpdatedAt = stamp
auditID, auditErr := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.claim", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion claimed bridge command")
if auditErr != nil {
return nil, auditErr
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return nil, err
}
claimed = append(claimed, domain.CopyGameClientBridgeCommand(command))
}
return claimed, nil
}
func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
command, err := svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.Claim.AcknowledgedAt = stamp
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
command.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.ack", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion acknowledged bridge command")
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if isTerminalGameClientBridgeCommandState(command.State) {
if command.Result.CompletedBy == component.Session.ID && gameClientBridgeClaimMatches(command, component, request.FencingToken) && command.Result.Status == request.Status && command.Result.Summary == request.Summary && reflect.DeepEqual(command.Result.Payload, request.Payload) {
return domain.CopyGameClientBridgeCommand(command), nil
}
return domain.GameClientBridgeCommand{}, validationError("bridge command already has a terminal result")
}
command, err = svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
switch request.Status {
case domain.GameClientBridgeResultSucceeded:
command.State = domain.GameClientBridgeCommandSucceeded
case domain.GameClientBridgeResultFailed:
command.State = domain.GameClientBridgeCommandFailed
case domain.GameClientBridgeResultCancelled:
command.State = domain.GameClientBridgeCommandCancelled
}
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
command.CompletedAt = stamp
command.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion recorded terminal bridge command result")
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if !canAccessServer(user, instance) {
return domain.GameClientBridgeCommand{}, ErrForbidden
}
if !isTerminalGameClientBridgeCommandState(command.State) && !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
}
if isTerminalGameClientBridgeCommandState(command.State) {
if command.State == domain.GameClientBridgeCommandCancelled {
return domain.CopyGameClientBridgeCommand(command), nil
}
return domain.GameClientBridgeCommand{}, validationError("bridge command is already terminal")
}
command.State = domain.GameClientBridgeCommandCancelled
command.Cancellation = domain.GameClientBridgeCancellation{RequestedBy: user.ID, Reason: request.Reason, CancelledAt: stamp}
command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp}
command.CompletedAt = stamp
command.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID(user.ID, "game-client-bridge.command.cancel", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "operator cancelled bridge command")
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) ReconcileGameClientBridgeCommands() error {
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
return err
}
if err := svc.pruneGameClientBridgeCommandsLocked(stamp); err != nil {
return err
}
return svc.pruneGameClientBridgeSnapshotsLocked(stamp)
}
func (svc *CoreService) pruneGameClientBridgeCommandsLocked(stamp time.Time) error {
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
if err != nil {
return err
}
groups := map[string][]domain.GameClientBridgeCommand{}
for _, command := range commands {
if !isTerminalGameClientBridgeCommandState(command.State) || command.CompletedAt.IsZero() {
continue
}
retention := defaultGameClientBridgeCommandRetention
maxRecords := 0
if plugin, pluginErr := svc.store.GamePlugins().Get(command.PluginID); pluginErr == nil {
if plugin.GameClientBridge.Retention.KeepForSeconds > 0 {
retention = time.Duration(plugin.GameClientBridge.Retention.KeepForSeconds) * time.Second
}
maxRecords = plugin.GameClientBridge.Retention.MaxRecords
}
if !command.CompletedAt.After(stamp.Add(-retention)) {
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
return err
}
continue
}
if maxRecords > 0 {
key := command.ServerInstanceID + "\x00" + command.PluginID
groups[key] = append(groups[key], command)
}
}
for _, group := range groups {
if len(group) == 0 {
continue
}
plugin, err := svc.store.GamePlugins().Get(group[0].PluginID)
if err != nil || plugin.GameClientBridge.Retention.MaxRecords <= 0 || len(group) <= plugin.GameClientBridge.Retention.MaxRecords {
continue
}
sort.SliceStable(group, func(left, right int) bool {
if !group[left].CompletedAt.Equal(group[right].CompletedAt) {
return group[left].CompletedAt.After(group[right].CompletedAt)
}
return group[left].ID < group[right].ID
})
for _, command := range group[plugin.GameClientBridge.Retention.MaxRecords:] {
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) pruneGameClientBridgeSnapshotsLocked(stamp time.Time) error {
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{})
if err != nil {
return err
}
groups := map[string][]domain.GameClientBridgeSnapshot{}
for _, snapshot := range snapshots {
if !snapshot.ExpiresAt.IsZero() && !snapshot.ExpiresAt.After(stamp) {
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
return err
}
continue
}
key := snapshot.ServerInstanceID + "\x00" + snapshot.PluginID + "\x00" + snapshot.ProfileKey + "\x00" + snapshot.Type + "\x00" + snapshot.StreamKey
groups[key] = append(groups[key], snapshot)
}
for _, group := range groups {
if len(group) == 0 {
continue
}
maxRecords := group[0].Retention.MaxRecords
if plugin, pluginErr := svc.store.GamePlugins().Get(group[0].PluginID); pluginErr == nil {
if declaration, ok := gameClientBridgeSnapshotDeclaration(plugin, group[0].Type, group[0].SchemaVersion); ok {
maxRecords = declaration.Retention.MaxRecords
}
}
if maxRecords <= 0 || len(group) <= maxRecords {
continue
}
sort.SliceStable(group, func(left, right int) bool {
if group[left].Sequence != group[right].Sequence {
return group[left].Sequence > group[right].Sequence
}
return group[left].ID < group[right].ID
})
for _, snapshot := range group[maxRecords:] {
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) error {
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
if err != nil {
return err
}
for _, command := range commands {
if isTerminalGameClientBridgeCommandState(command.State) {
continue
}
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
return err
}
continue
}
if command.State == domain.GameClientBridgeCommandClaimed && !command.Claim.LeaseExpiresAt.IsZero() && !command.Claim.LeaseExpiresAt.After(stamp) {
fencingToken := command.Claim.FencingToken
command.State = domain.GameClientBridgeCommandPending
command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken}
command.UpdatedAt = stamp
auditID, auditErr := svc.recordAuditEventWithID("platform", "game-client-bridge.command.lease-expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "expired bridge claim returned to pending")
if auditErr != nil {
return auditErr
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) fencedGameClientBridgeCommand(component gameClientBridgeComponentSession, commandID string, fencingToken uint64, stamp time.Time) (domain.GameClientBridgeCommand, error) {
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if command.State != domain.GameClientBridgeCommandClaimed || !gameClientBridgeClaimMatches(command, component, fencingToken) {
return domain.GameClientBridgeCommand{}, validationError("bridge command claim is stale")
}
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
}
if !command.Claim.LeaseExpiresAt.After(stamp) {
return domain.GameClientBridgeCommand{}, validationError("bridge command claim lease expired")
}
return command, nil
}
func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error {
if isTerminalGameClientBridgeCommandState(command.State) {
return nil
}
command.State = domain.GameClientBridgeCommandExpired
command.CompletedAt = stamp
command.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID("platform", "game-client-bridge.command.expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "bridge command expired before completion")
if err != nil {
return err
}
command.AuditReferences = append(command.AuditReferences, auditID)
return svc.store.GameClientBridgeCommands().Update(command)
}
func gameClientBridgeClaimLeaseExpiry(stamp, commandExpiry time.Time) time.Time {
leaseExpiry := stamp.Add(defaultGameClientBridgeLeaseDuration)
if !commandExpiry.IsZero() && commandExpiry.Before(leaseExpiry) {
return commandExpiry
}
return leaseExpiry
}
func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, component gameClientBridgeComponentSession, fencingToken uint64) bool {
return command.Claim.SessionID == component.Session.ID && command.Claim.InstallationID == component.Installation.ID && command.Claim.DeploymentGeneration == component.Session.DeploymentGeneration && command.Claim.FencingToken == fencingToken
}
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
switch state {
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
return true
default:
return false
}
}
@@ -0,0 +1,184 @@
package service
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const gameClientBridgeCapability = "game-client.bridge"
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
return nil, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return nil, err
}
return svc.claimGameClientBridgeCommands(component, request.Limit)
}
func (svc *CoreService) AckGameClientBridgeCommand(request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
return svc.ackGameClientBridgeCommand(component, request)
}
func (svc *CoreService) CompleteGameClientBridgeCommand(request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
return svc.completeGameClientBridgeCommand(component, request)
}
func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeSnapshotIngestRequest(request); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
stamp := svc.now()
if request.ObservedAt.After(stamp.Add(5 * time.Minute)) {
return domain.GameClientBridgeSnapshot{}, validationError("snapshot observedAt is too far in the future")
}
plugin, err := svc.store.GamePlugins().Get(component.Installation.PluginID)
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
declaration, declared := gameClientBridgeSnapshotDeclaration(plugin, request.Type, request.SchemaVersion)
if !declared {
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot type and schema version are not declared")
}
if request.Retention != declaration.Retention {
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot retention does not match declaration")
}
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
streamID := gameClientBridgeStreamID(component.Session.ServerInstanceID, component.Installation.PluginID, component.Session.ProfileKey, request.Type, request.StreamKey)
latestSequence := uint64(0)
stream, streamErr := svc.store.GameClientBridgeSnapshotStreams().Get(streamID)
if streamErr == nil {
latestSequence = stream.LatestSequence
} else if streamErr != repo.ErrNotFound {
return domain.GameClientBridgeSnapshot{}, streamErr
}
existing, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, StreamKey: request.StreamKey})
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
for _, snapshot := range existing {
if snapshot.Sequence > latestSequence {
latestSequence = snapshot.Sequence
}
}
if request.Sequence <= latestSequence {
return domain.GameClientBridgeSnapshot{}, validationError("snapshot sequence is stale")
}
svc.bridgeSeq++
snapshot := domain.GameClientBridgeSnapshot{
ID: fmt.Sprintf("bridge-snapshot-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
ServerInstanceID: component.Session.ServerInstanceID,
PluginID: component.Installation.PluginID,
ProfileKey: component.Session.ProfileKey,
Type: request.Type,
SchemaVersion: request.SchemaVersion,
StreamKey: request.StreamKey,
Sequence: request.Sequence,
SourceSessionID: component.Session.ID,
ObservedAt: request.ObservedAt,
Payload: domain.CopyGameClientBridgePayload(request.Payload),
Retention: request.Retention,
CreatedAt: stamp,
ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second),
}
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.snapshot.ingest", "game-client-bridge-snapshot", snapshot.ID, domain.AuditResultSuccess, "companion uploaded typed bridge snapshot")
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
snapshot.AuditReferences = []string{auditID}
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
stream = domain.GameClientBridgeSnapshotStream{ID: streamID, ServerInstanceID: snapshot.ServerInstanceID, PluginID: snapshot.PluginID, ProfileKey: snapshot.ProfileKey, Type: snapshot.Type, StreamKey: snapshot.StreamKey, LatestSequence: snapshot.Sequence, UpdatedAt: stamp}
if streamErr == repo.ErrNotFound {
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
} else {
if err := svc.store.GameClientBridgeSnapshotStreams().Update(stream); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
}
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
}
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
for _, declaration := range plugin.GameClientBridge.Snapshots {
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
return declaration, true
}
}
return domain.GameClientBridgeSnapshotDeclaration{}, false
}
func (svc *CoreService) authorizeGameClientBridgeSession(sessionToken string) (gameClientBridgeComponentSession, error) {
presentedHash := tokenHash(sessionToken)
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{Status: domain.ClientManagerSessionActive})
if err != nil {
return gameClientBridgeComponentSession{}, err
}
var session domain.ClientManagerSession
for _, candidate := range sessions {
if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(presentedHash)) == 1 {
session = candidate
break
}
}
stamp := svc.now()
if session.ID == "" || !stamp.Before(session.ExpiresAt) {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if !containsString(session.Capabilities, gameClientBridgeCapability) {
return gameClientBridgeComponentSession{}, ErrForbidden
}
installation, err := svc.store.ClientManagerInstallations().Get(session.InstallationID)
if err != nil {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if installation.ServerInstanceID != session.ServerInstanceID || installation.ProfileKey != session.ProfileKey || installation.RunEndpointID != session.RunEndpointID || installation.ActiveArtifactID != session.ArtifactID || installation.KeyGeneration != session.KeyGeneration || installation.DeploymentGeneration != session.DeploymentGeneration || installation.RequiresRedeploy {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
key, err := svc.activeComponentKey(session.ServerInstanceID, domain.DistributionComponentClientManager, session.ProfileKey)
if err != nil || key.Generation != session.KeyGeneration {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
return gameClientBridgeComponentSession{Session: domain.CopyClientManagerSession(session), Installation: domain.CopyClientManagerInstallation(installation)}, nil
}
func gameClientBridgeStreamID(serverInstanceID, pluginID, profileKey, snapshotType, streamKey string) string {
digest := sha256.Sum256([]byte(serverInstanceID + "\x00" + pluginID + "\x00" + profileKey + "\x00" + snapshotType + "\x00" + streamKey))
return "bridge-stream-" + hex.EncodeToString(digest[:16])
}
@@ -0,0 +1,194 @@
package service
import (
"errors"
"testing"
"time"
"browser.local/platform/domain"
)
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
t.Helper()
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
t.Fatal(err)
}
if err := svc.store.ClientManagerSessions().Create(session); err != nil {
t.Fatal(err)
}
if err := svc.store.EncryptedComponentKeys().Create(key); err != nil {
t.Fatal(err)
}
return installation, session
}
func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
const token = "component-session-token"
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authorized-1"))
if err != nil {
t.Fatal(err)
}
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token, Limit: 5})
if err != nil || len(claimed) != 1 || claimed[0].ID != command.ID || claimed[0].Claim.SessionID != session.ID {
t.Fatalf("authorized claim: %#v err=%v", claimed, err)
}
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
t.Fatalf("authorized ack: %v", err)
}
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil {
t.Fatalf("authorized result: %v", err)
}
snapshotRequest := domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}
snapshot, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest)
if err != nil || snapshot.SourceSessionID != session.ID || snapshot.Sequence != 1 {
t.Fatalf("authorized snapshot: %#v err=%v", snapshot, err)
}
if snapshot.SourceSessionID == token {
t.Fatal("raw component token persisted in snapshot")
}
higherSnapshotRequest := snapshotRequest
higherSnapshotRequest.Sequence = 2
higherSnapshot, err := svc.UploadGameClientBridgeSnapshot(higherSnapshotRequest)
if err != nil || higherSnapshot.Sequence != 2 {
t.Fatalf("higher snapshot sequence was not accepted: %#v err=%v", higherSnapshot, err)
}
equalSnapshotRequest := higherSnapshotRequest
if _, err := svc.UploadGameClientBridgeSnapshot(equalSnapshotRequest); err == nil {
t.Fatal("expected latest snapshot sequence to be rejected")
}
if _, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest); err == nil {
t.Fatal("expected lower snapshot sequence rejection")
}
currentSnapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current"})
if err != nil || len(currentSnapshots) != 2 {
t.Fatalf("stale snapshot attempts changed current stream records: %#v err=%v", currentSnapshots, err)
}
isolatedStreamRequest := snapshotRequest
isolatedStreamRequest.StreamKey = "secondary"
if snapshot, err := svc.UploadGameClientBridgeSnapshot(isolatedStreamRequest); err != nil || snapshot.Sequence != 1 {
t.Fatalf("independent snapshot stream did not start at sequence one: %#v err=%v", snapshot, err)
}
stream, err := svc.store.GameClientBridgeSnapshotStreams().Get(gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"))
if err != nil || stream.LatestSequence != 2 {
t.Fatalf("snapshot stream projection: %#v err=%v", stream, err)
}
}
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
const firstToken = "component-session-token-one"
_, firstSession := seedGameClientBridgeComponentSession(t, svc, *clock, firstToken)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "session-owner-1"))
if err != nil {
t.Fatal(err)
}
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: firstToken, Limit: 1})
if err != nil || len(claimed) != 1 {
t.Fatalf("first session claim: %#v err=%v", claimed, err)
}
const secondToken = "component-session-token-two"
secondSession := firstSession
secondSession.ID = "component-session-2"
secondSession.TokenHash = tokenHash(secondToken)
if err := svc.store.ClientManagerSessions().Create(secondSession); err != nil {
t.Fatalf("create second current session: %v", err)
}
request := domain.GameClientBridgeAckRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}
if _, err := svc.AckGameClientBridgeCommand(request); err == nil {
t.Fatal("expected second session ack to be rejected")
}
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
t.Fatal("expected second session result to be rejected")
}
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: firstToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
t.Fatalf("claim owner could not ack after rejected second session: %v", err)
}
}
func TestGameClientBridgeComponentSessionRejectsMissingCapabilityAndStaleFences(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
const token = "component-session-token"
installation, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authz-1")); err != nil {
t.Fatal(err)
}
session.Capabilities = []string{"component.heartbeat"}
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected missing capability rejection, got %v", err)
}
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
t.Fatal(err)
}
installation.DeploymentGeneration++
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected deployment fence rejection, got %v", err)
}
installation.DeploymentGeneration = session.DeploymentGeneration
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
installation.ActiveArtifactID = "artifact-2"
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected active artifact fence rejection, got %v", err)
}
installation.ActiveArtifactID = session.ArtifactID
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
installation.KeyGeneration++
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected installation key-generation fence rejection, got %v", err)
}
installation.KeyGeneration = session.KeyGeneration
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
key, err := svc.store.EncryptedComponentKeys().Get("key-1")
if err != nil {
t.Fatal(err)
}
key.Generation++
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected active component key-generation rejection, got %v", err)
}
key.Generation = session.KeyGeneration
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
t.Fatal(err)
}
session.ExpiresAt = *clock
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
t.Fatal(err)
}
if _, err := svc.UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"healthy": true}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected expired session rejection, got %v", err)
}
}
+379
View File
@@ -0,0 +1,379 @@
package service
import (
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
t.Helper()
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
store := repo.NewMemoryStore()
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
if err := store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("seed bridge plugin: %v", err)
}
svc := newCoreService(store, func() time.Time { return now })
return svc, &now
}
func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest {
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
}
func bridgeComponent() gameClientBridgeComponentSession {
return gameClientBridgeComponentSession{
Session: domain.ClientManagerSession{ID: "component-session-1", ServerInstanceID: "server-1", ProfileKey: "scum-client", DeploymentGeneration: 3},
Installation: domain.ClientManagerInstallation{ID: "installation-1", PluginID: "game.scum"},
}
}
func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
request := bridgeQueueRequest(*clock, "announce-1")
command, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil {
t.Fatalf("queue bridge command: %v", err)
}
duplicate, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil || duplicate.ID != command.ID {
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
}
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
if len(commands) != 1 || len(command.AuditReferences) != 1 {
t.Fatalf("expected one durable audited command: %#v", commands)
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 10)
if err != nil || len(claimed) != 1 || claimed[0].State != domain.GameClientBridgeCommandClaimed || claimed[0].Claim.FencingToken != 1 {
t.Fatalf("claim bridge command: %#v err=%v", claimed, err)
}
initialLeaseExpiry := claimed[0].Claim.LeaseExpiresAt
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 2}); err == nil {
t.Fatal("expected stale fencing token rejection")
}
*clock = clock.Add(10 * time.Second)
acked, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1})
if err != nil || acked.Claim.AcknowledgedAt.IsZero() || !acked.Claim.LeaseExpiresAt.Equal(clock.Add(defaultGameClientBridgeLeaseDuration)) || !acked.Claim.LeaseExpiresAt.After(initialLeaseExpiry) {
t.Fatalf("ack bridge command: %#v err=%v", acked, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Payload: map[string]any{"sessionToken": "must-not-persist"}}); err == nil {
t.Fatal("expected unsafe result material to be rejected")
}
resultRequest := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered", Payload: map[string]any{"delivered": true}}
completed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
if err != nil || completed.State != domain.GameClientBridgeCommandSucceeded || completed.Result.Status != domain.GameClientBridgeResultSucceeded || completed.CompletedAt.IsZero() {
t.Fatalf("complete bridge command: %#v err=%v", completed, err)
}
if len(completed.AuditReferences) < 3 {
t.Fatalf("expected queue, claim, and result audit references: %#v", completed.AuditReferences)
}
auditReferenceCount := len(completed.AuditReferences)
replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) || len(replayed.AuditReferences) != auditReferenceCount {
t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil {
t.Fatal("expected conflicting terminal result rejection")
}
}
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
request := bridgeQueueRequest(*clock, "scope-key")
first, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil {
t.Fatal(err)
}
request.Payload = map[string]any{"message": "changed but same idempotency scope"}
reused, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil || reused.ID != first.ID {
t.Fatalf("same service idempotency scope was not reused: first=%#v reused=%#v err=%v", first, reused, err)
}
otherRequester, err := svc.queueGameClientBridgeCommand("user-2", request)
if err != nil || otherRequester.ID == first.ID {
t.Fatalf("requester was omitted from idempotency scope: %#v err=%v", otherRequester, err)
}
request.IdempotencyKey = "scope-key-2"
otherKey, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil || otherKey.ID == first.ID {
t.Fatalf("idempotency key was omitted from service scope: %#v err=%v", otherKey, err)
}
request.ServerInstanceID = "server-2"
request.IdempotencyKey = "scope-key"
otherServer, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil || otherServer.ID == first.ID {
t.Fatalf("server was omitted from service idempotency scope: %#v err=%v", otherServer, err)
}
}
func TestGameClientBridgeLeaseReclaimAndExpiry(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "lease-1"))
if err != nil {
t.Fatal(err)
}
component := bridgeComponent()
first, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(first) != 1 {
t.Fatalf("first claim: %#v err=%v", first, err)
}
*clock = clock.Add(defaultGameClientBridgeLeaseDuration + time.Second)
second, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(second) != 1 || second[0].Claim.FencingToken != 2 {
t.Fatalf("reclaim expired lease: %#v err=%v", second, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
t.Fatal("expected old claim fencing rejection")
}
*clock = command.ExpiresAt.Add(time.Second)
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
t.Fatalf("reconcile expired command: %v", err)
}
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired {
t.Fatalf("expected expired command: %#v err=%v", expired, err)
}
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(claimed) != 0 {
t.Fatalf("expired command was claimable: %#v err=%v", claimed, err)
}
}
func TestGameClientBridgePendingCommandExpiresBeforeFirstClaim(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
request := bridgeQueueRequest(*clock, "pending-expiry")
request.ExpiresAt = clock.Add(30 * time.Second)
command, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil {
t.Fatal(err)
}
*clock = request.ExpiresAt
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
if err != nil || len(claimed) != 0 {
t.Fatalf("expired pending command was claimable: %#v err=%v", claimed, err)
}
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 || len(expired.AuditReferences) != 2 {
t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err)
}
}
func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
for _, key := range []string{"expired-lease-ack", "expired-lease-result"} {
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, key)); err != nil {
t.Fatalf("queue %s: %v", key, err)
}
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
if err != nil || len(claimed) != 2 {
t.Fatalf("claim lease-expiry commands: %#v err=%v", claimed, err)
}
*clock = claimed[0].Claim.LeaseExpiresAt
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
t.Fatal("expected ack at claim lease expiry to be rejected")
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
t.Fatal("expected result at claim lease expiry to be rejected")
}
for _, command := range claimed {
protected, getErr := svc.store.GameClientBridgeCommands().Get(command.ID)
if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken || len(protected.AuditReferences) != 2 {
t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr)
}
}
reclaimed, err := svc.claimGameClientBridgeCommands(component, 2)
if err != nil || len(reclaimed) != 2 {
t.Fatalf("reclaim protected commands after lease sweep: %#v err=%v", reclaimed, err)
}
for _, command := range reclaimed {
if command.Claim.FencingToken != 2 {
t.Fatalf("reclaimed command did not advance fencing token: %#v", command)
}
}
}
func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
deadline := clock.Add(30 * time.Second)
commands := make([]domain.GameClientBridgeCommand, 0, 2)
for _, key := range []string{"deadline-ack", "deadline-result"} {
request := bridgeQueueRequest(*clock, key)
request.ExpiresAt = deadline
command, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil {
t.Fatalf("queue deadline command: %v", err)
}
commands = append(commands, command)
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
if err != nil || len(claimed) != 2 {
t.Fatalf("claim deadline commands: %#v err=%v", claimed, err)
}
for _, command := range claimed {
if !command.Claim.LeaseExpiresAt.Equal(deadline) {
t.Fatalf("claim lease exceeded command deadline: %#v", command.Claim)
}
}
*clock = deadline
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
t.Fatal("expected ack at command deadline to be rejected")
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
t.Fatal("expected result at command deadline to be rejected")
}
for _, command := range commands {
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || len(expired.AuditReferences) < 3 {
t.Fatalf("deadline mutation did not persist audited expiry: %#v err=%v", expired, err)
}
}
}
func TestGameClientBridgeFailedResultIsPersisted(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "failed-result"))
if err != nil {
t.Fatal(err)
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(claimed) != 1 {
t.Fatalf("claim failed-result command: %#v err=%v", claimed, err)
}
request := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "game window unavailable", Payload: map[string]any{"retryable": true}}
failed, err := svc.completeGameClientBridgeCommand(component, request)
if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() || len(failed.AuditReferences) != 3 {
t.Fatalf("record failed result: %#v err=%v", failed, err)
}
persisted, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) || len(persisted.AuditReferences) != len(failed.AuditReferences) {
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
}
}
func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
if err := svc.store.Users().Create(user); err != nil {
t.Fatal(err)
}
auth, err := svc.issueAuthSession(user, "test")
if err != nil {
t.Fatal(err)
}
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
t.Fatal(err)
}
request := bridgeQueueRequest(*clock, "cancel-deadline")
request.ExpiresAt = clock.Add(30 * time.Second)
command, err := svc.queueGameClientBridgeCommand(user.ID, request)
if err != nil {
t.Fatal(err)
}
*clock = request.ExpiresAt
if _, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "too late"}); err == nil {
t.Fatal("expected cancellation at command deadline to be rejected")
}
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Cancellation.RequestedBy != "" || len(expired.AuditReferences) != 2 {
t.Fatalf("deadline cancellation did not preserve audited expiry: %#v err=%v", expired, err)
}
}
func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
if err := svc.store.Users().Create(user); err != nil {
t.Fatal(err)
}
auth, err := svc.issueAuthSession(user, "test")
if err != nil {
t.Fatal(err)
}
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
t.Fatal(err)
}
command, err := svc.queueGameClientBridgeCommand(user.ID, bridgeQueueRequest(*clock, "cancel-1"))
if err != nil {
t.Fatal(err)
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(claimed) != 1 {
t.Fatalf("claim: %#v err=%v", claimed, err)
}
cancelled, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID {
t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err)
}
auditReferenceCount := len(cancelled.AuditReferences)
repeated, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) || len(repeated.AuditReferences) != auditReferenceCount {
t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err)
}
remaining, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(remaining) != 0 {
t.Fatalf("cancelled command remained claimable: %#v err=%v", remaining, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "late success"}); err == nil {
t.Fatal("expected late success after cancellation rejection")
}
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
t.Fatal("expected late ack after cancellation rejection")
}
}
func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequence(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
plugin, err := svc.store.GamePlugins().Get("game.scum")
if err != nil {
t.Fatal(err)
}
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}
plugin.GameClientBridge.Snapshots[0].Retention = domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
oldCommand := domain.GameClientBridgeCommand{ID: "old-command", ServerInstanceID: "server-1", PluginID: "game.scum", State: domain.GameClientBridgeCommandSucceeded, CompletedAt: clock.Add(-2 * time.Hour)}
if err := svc.store.GameClientBridgeCommands().Create(oldCommand); err != nil {
t.Fatal(err)
}
for sequence := uint64(1); sequence <= 4; sequence++ {
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}, ExpiresAt: clock.Add(time.Hour)}
if sequence == 1 {
snapshot.ExpiresAt = clock.Add(-time.Second)
}
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
t.Fatal(err)
}
}
stream := domain.GameClientBridgeSnapshotStream{ID: gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 4}
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
t.Fatal(err)
}
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
t.Fatalf("reconcile bridge retention: %v", err)
}
if _, err := svc.store.GameClientBridgeCommands().Get(oldCommand.ID); err == nil {
t.Fatal("old terminal command was not pruned")
}
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", Type: "players"})
if err != nil || len(snapshots) != 2 || snapshots[0].Sequence != 4 || snapshots[1].Sequence != 3 {
t.Fatalf("snapshot retention projection: %#v err=%v", snapshots, err)
}
retainedStream, err := svc.store.GameClientBridgeSnapshotStreams().Get(stream.ID)
if err != nil || retainedStream.LatestSequence != 4 {
t.Fatalf("stream sequence was reset by retention: %#v err=%v", retainedStream, err)
}
}
+4 -1
View File
@@ -255,6 +255,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.projectProductionOpsJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
}
@@ -597,7 +600,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
State: job.State,
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
ResultRef: job.ResultRef,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs)},
LeaseToken: leaseToken,
Attempt: job.Attempt,
MaxAttempts: job.RetryPolicy.MaxAttempts,
+992
View File
@@ -0,0 +1,992 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const (
capacityHeartbeatStaleAfter = 2 * time.Minute
capacityRetryAfterSeconds = 30
capacityLogBacklogLimit = 256
capacityArtifactBacklogLimit = 128
aiConfigDiffTTL = 30 * time.Minute
)
func (svc *CoreService) GetProductionCapacityForSession(sessionID string) (domain.ProductionCapacitySummary, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.ProductionCapacitySummary{}, err
}
endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{})
if err != nil {
return domain.ProductionCapacitySummary{}, err
}
visibleEndpointIDs, err := svc.visibleEndpointIDs(user)
if err != nil {
return domain.ProductionCapacitySummary{}, err
}
alerts, err := svc.store.Alerts().List(domain.AlertFilter{})
if err != nil {
return domain.ProductionCapacitySummary{}, err
}
summary := domain.ProductionCapacitySummary{GeneratedAt: svc.now()}
for _, endpoint := range endpoints {
if !isPlatformAdmin(user) {
if _, visible := visibleEndpointIDs[endpoint.ID]; !visible {
continue
}
}
running, queued, err := svc.capacityJobCounts(endpoint.ID)
if err != nil {
return domain.ProductionCapacitySummary{}, err
}
projection := svc.capacityProjection(endpoint, running, queued)
for _, alert := range alerts {
if alert.SourceKind == "run-endpoint" && alert.SourceID == endpoint.ID && alert.RuleKey == "capacity.pressure" && alert.State != domain.AlertStateResolved {
projection.LastAdmissionDecision = domain.CapacityAdmissionDeferred
projection.LastAdmissionReason = alert.Message
projection.LastAdmissionCheckedAt = alert.LastSeenAt
}
}
summary.Endpoints = append(summary.Endpoints, projection)
summary.TotalMaxJobs += projection.MaxJobs
summary.TotalRunningJobs += projection.RunningJobs
summary.TotalQueuedJobs += projection.QueuedJobs
}
for _, alert := range alerts {
if alert.State != domain.AlertStateResolved && svc.canAccessAlert(user, alert) {
summary.ActiveAlerts++
}
}
sort.Slice(summary.Endpoints, func(i, j int) bool { return summary.Endpoints[i].RunEndpointID < summary.Endpoints[j].RunEndpointID })
return domain.CopyProductionCapacitySummary(summary), nil
}
func (svc *CoreService) CheckCapacityAdmissionForSession(sessionID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
if err := validator.ValidateCapacityAdmissionRequest(request); err != nil {
return domain.CapacityAdmissionDecision{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.CapacityAdmissionDecision{}, err
}
request, err = svc.authorizeCapacityRequest(user, request)
if err != nil {
return domain.CapacityAdmissionDecision{}, err
}
svc.productionMu.Lock()
defer svc.productionMu.Unlock()
return svc.checkCapacityAdmission(user.ID, request)
}
func (svc *CoreService) checkCapacityAdmission(actorID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
endpoint, err := svc.store.RunEndpoints().Get(request.RunEndpointID)
if err != nil {
return domain.CapacityAdmissionDecision{}, err
}
running, queued, err := svc.capacityJobCounts(endpoint.ID)
if err != nil {
return domain.CapacityAdmissionDecision{}, err
}
projection := svc.capacityProjection(endpoint, running, queued)
decision := domain.CapacityAdmissionDecision{
Accepted: true, State: domain.CapacityAdmissionAccepted, Reason: "capacity available",
ServerInstanceID: request.ServerInstanceID, RunEndpointID: endpoint.ID, Capability: request.Capability,
TargetKey: request.TargetKey, MaxJobs: projection.MaxJobs, RunningJobs: projection.RunningJobs,
QueuedJobs: projection.QueuedJobs, CheckedAt: svc.now(),
}
pressure := append([]domain.CapacityPressureCode(nil), projection.PressureCodes...)
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{request.Capability})) > 0 {
pressure = appendCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
}
decision.PressureCodes = pressure
hardDenied := containsCapacityPressure(pressure, domain.CapacityPressureEndpointOffline) || containsCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
if hardDenied {
decision.Accepted = false
decision.State = domain.CapacityAdmissionDenied
decision.Reason = "endpoint is unavailable or missing the required capability"
} else if len(pressure) > 0 {
decision.Accepted = false
decision.State = domain.CapacityAdmissionDeferred
decision.Reason = "endpoint capacity is temporarily under pressure"
decision.RetryAfterSeconds = capacityRetryAfterSeconds
}
auditResult := domain.AuditResultSuccess
auditAction := "capacity.admission.accepted"
if !decision.Accepted {
auditResult = domain.AuditResultDenied
auditAction = "capacity.admission.denied"
}
auditID, err := svc.recordAuditEventWithID(actorID, auditAction, "run-endpoint", endpoint.ID, auditResult, decision.Reason)
if err != nil {
return domain.CapacityAdmissionDecision{}, err
}
decision.AuditEventID = auditID
if !decision.Accepted {
severity := domain.AlertSeverityWarning
if hardDenied {
severity = domain.AlertSeverityCritical
}
alert, err := svc.upsertAlert(domain.AlertRecord{
SourceKind: "run-endpoint", SourceID: endpoint.ID, RuleKey: "capacity.pressure", Severity: severity,
Title: "Run endpoint capacity admission blocked", Message: decision.Reason, Retryable: true,
RetryAfterSeconds: decision.RetryAfterSeconds, LastAuditEventID: auditID,
})
if err != nil {
return domain.CapacityAdmissionDecision{}, err
}
decision.AlertID = alert.ID
} else if err := svc.resolveAlertForSource("run-endpoint", endpoint.ID, "capacity.pressure", actorID, "capacity returned to an admissible state", auditID); err != nil {
return domain.CapacityAdmissionDecision{}, err
}
if err := validator.ValidateCapacityAdmissionDecision(decision); err != nil {
return domain.CapacityAdmissionDecision{}, err
}
return domain.CopyCapacityAdmissionDecision(decision), nil
}
func (svc *CoreService) ListAlertsForSession(sessionID string, filter domain.AlertFilter) ([]domain.AlertRecord, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return nil, err
}
alerts, err := svc.store.Alerts().List(filter)
if err != nil {
return nil, err
}
visible := make([]domain.AlertRecord, 0, len(alerts))
for _, alert := range alerts {
if svc.canAccessAlert(user, alert) {
visible = append(visible, alert)
}
}
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
return domain.CopyAlertRecords(visible), nil
}
func (svc *CoreService) AcknowledgeAlertForSession(sessionID string, request domain.AlertAcknowledgeRequest) (domain.AlertRecord, error) {
if err := validator.ValidateAlertAcknowledgeRequest(request); err != nil {
return domain.AlertRecord{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.AlertRecord{}, err
}
svc.productionMu.Lock()
defer svc.productionMu.Unlock()
alert, err := svc.store.Alerts().Get(request.AlertID)
if err != nil {
return domain.AlertRecord{}, err
}
if !svc.canAccessAlert(user, alert) {
return domain.AlertRecord{}, ErrForbidden
}
if alert.State == domain.AlertStateResolved {
return domain.AlertRecord{}, validationError("resolved alerts cannot be acknowledged")
}
stamp := svc.now()
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.acknowledge", "alert", alert.ID, domain.AuditResultSuccess, defaultAlertNote(request.Note, "alert acknowledged"))
if err != nil {
return domain.AlertRecord{}, err
}
alert.State = domain.AlertStateAcknowledged
alert.AcknowledgedBy = user.ID
alert.AcknowledgedAt = stamp
alert.LastAuditEventID = auditID
alert.UpdatedAt = stamp
if err := validator.ValidateAlertRecord(alert); err != nil {
return domain.AlertRecord{}, err
}
if err := svc.store.Alerts().Update(alert); err != nil {
return domain.AlertRecord{}, err
}
return domain.CopyAlertRecord(alert), nil
}
func (svc *CoreService) ResolveAlertForSession(sessionID string, request domain.AlertResolveRequest) (domain.AlertRecord, error) {
if err := validator.ValidateAlertResolveRequest(request); err != nil {
return domain.AlertRecord{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.AlertRecord{}, err
}
svc.productionMu.Lock()
defer svc.productionMu.Unlock()
alert, err := svc.store.Alerts().Get(request.AlertID)
if err != nil {
return domain.AlertRecord{}, err
}
if !svc.canAccessAlert(user, alert) {
return domain.AlertRecord{}, ErrForbidden
}
if alert.State == domain.AlertStateResolved {
return domain.CopyAlertRecord(alert), nil
}
stamp := svc.now()
note := defaultAlertNote(request.Note, "alert resolved after operator review")
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.resolve", "alert", alert.ID, domain.AuditResultSuccess, note)
if err != nil {
return domain.AlertRecord{}, err
}
alert.State = domain.AlertStateResolved
alert.ResolvedBy = user.ID
alert.ResolvedAt = stamp
alert.ResolutionNote = note
alert.LastAuditEventID = auditID
alert.UpdatedAt = stamp
if err := validator.ValidateAlertRecord(alert); err != nil {
return domain.AlertRecord{}, err
}
if err := svc.store.Alerts().Update(alert); err != nil {
return domain.AlertRecord{}, err
}
return domain.CopyAlertRecord(alert), nil
}
func (svc *CoreService) RetryAlertForSession(sessionID string, request domain.AlertRetryRequest) (domain.AlertRetryResult, error) {
if err := validator.ValidateAlertRetryRequest(request); err != nil {
return domain.AlertRetryResult{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.AlertRetryResult{}, err
}
alert, err := svc.store.Alerts().Get(request.AlertID)
if err != nil {
return domain.AlertRetryResult{}, err
}
if !svc.canAccessAlert(user, alert) {
return domain.AlertRetryResult{}, ErrForbidden
}
if !alert.Retryable {
return domain.AlertRetryResult{}, validationError("alert source is not retryable")
}
switch alert.SourceKind {
case "run-endpoint":
endpoint, err := svc.store.RunEndpoints().Get(alert.SourceID)
if err != nil {
return domain.AlertRetryResult{}, err
}
capability := firstCapacityCapability(endpoint.Capabilities)
decision, err := svc.CheckCapacityAdmissionForSession(sessionID, domain.CapacityAdmissionRequest{RunEndpointID: endpoint.ID, Capability: capability, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.AlertRetryResult{}, err
}
updated, err := svc.store.Alerts().Get(alert.ID)
if err != nil {
return domain.AlertRetryResult{}, err
}
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: updated, Decision: decision, Status: string(decision.State)}), nil
case "plugin-lifecycle":
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
if err != nil {
return domain.AlertRetryResult{}, err
}
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, Operation: installation.LastOperation, TargetVersion: installation.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: true})
if err != nil {
return domain.AlertRetryResult{}, err
}
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: alert, Decision: result.Decision, Status: result.Status}), nil
default:
return domain.AlertRetryResult{}, validationError("alert source does not support scoped retry")
}
}
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return nil, err
}
installations, err := svc.store.PluginLifecycles().List(filter)
if err != nil {
return nil, err
}
visible := make([]domain.PluginLifecycleInstallation, 0, len(installations))
for _, installation := range installations {
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
if err == nil && canAccessServer(user, instance) {
visible = append(visible, installation)
}
}
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
return domain.CopyPluginLifecycleInstallations(visible), nil
}
func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error) {
if err := validator.ValidatePluginLifecycleRequest(request); err != nil {
return domain.PluginLifecycleResult{}, err
}
user, instance, err := svc.requireServerOwner(sessionID, request.ServerInstanceID)
if err != nil {
return domain.PluginLifecycleResult{}, err
}
if instance.PluginID != request.PluginID {
return domain.PluginLifecycleResult{}, validationError("pluginId must match the server plugin")
}
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
if err != nil {
return domain.PluginLifecycleResult{}, err
}
if !containsString(plugin.ProductionLifecycle.Operations, string(request.Operation)) {
return domain.PluginLifecycleResult{}, validationError("plugin lifecycle operation is not declared by the manifest")
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.PluginLifecycleResult{}, err
}
capability, targetKey, err := pluginLifecycleDispatchMetadata(plugin, request.Operation)
if err != nil {
return domain.PluginLifecycleResult{}, err
}
if request.TargetVersion == "" {
request.TargetVersion = plugin.Version
}
if !containsString(plugin.SupportedOS, endpoint.Platform) {
return svc.pluginLifecycleDenied(user.ID, instance, plugin, request, "plugin is not compatible with the assigned endpoint platform")
}
svc.productionMu.Lock()
defer svc.productionMu.Unlock()
installationID := pluginLifecycleInstallationID(request.PluginID, request.ServerInstanceID)
installation, getErr := svc.store.PluginLifecycles().Get(installationID)
if errors.Is(getErr, repo.ErrNotFound) {
stamp := svc.now()
installation = domain.PluginLifecycleInstallation{ID: installationID, PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStatePending, Compatibility: "compatible", DependencyState: domain.DependencyStateUnknown, CreatedAt: stamp, UpdatedAt: stamp}
} else if getErr != nil {
return domain.PluginLifecycleResult{}, getErr
}
if err := validatePluginLifecycleTransition(installation, request); err != nil {
return svc.pluginLifecycleDeniedLocked(user.ID, installation, request, err.Error())
}
existingJob, jobErr := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey)
if jobErr == nil {
if existingJob.ServerInstanceID != instance.ID || existingJob.Capability != capability || existingJob.TargetKey != targetKey || existingJob.ExecutionInput.PluginID != plugin.ID || existingJob.ExecutionInput.LifecycleOperation != string(request.Operation) || existingJob.ExecutionInput.TargetVersion != request.TargetVersion {
return domain.PluginLifecycleResult{}, validationError("idempotencyKey is already used for different plugin lifecycle inputs")
}
installation.JobID = existingJob.ID
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: existingJob, Status: "queued"}), nil
}
if !errors.Is(jobErr, repo.ErrNotFound) {
return domain.PluginLifecycleResult{}, jobErr
}
decision, err := svc.checkCapacityAdmission(user.ID, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.PluginLifecycleResult{}, err
}
if !decision.Accepted {
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Decision: decision, Status: string(decision.State)}), nil
}
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-plugin-lifecycle", installation.ID, request.IdempotencyKey), ServerInstanceID: instance.ID,
RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), PluginID: plugin.ID, LifecycleOperation: string(request.Operation), TargetVersion: request.TargetVersion},
Progress: domain.JobProgress{Percent: 0, Message: "plugin lifecycle operation queued"},
})
if err != nil {
return domain.PluginLifecycleResult{}, err
}
stamp := svc.now()
installation.PreviousVersion = installation.CurrentVersion
installation.TargetVersion = request.TargetVersion
installation.DesiredState = desiredPluginLifecycleState(request.Operation, installation)
installation.CurrentState = dispatchedPluginLifecycleState(request.Operation, installation.CurrentState)
installation.LastOperation = request.Operation
installation.JobID = job.ID
installation.IdempotencyKey = request.IdempotencyKey
installation.FailureReason = ""
installation.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID(user.ID, "plugin.lifecycle."+string(request.Operation), "plugin-lifecycle", installation.ID, domain.AuditResultQueued, "plugin lifecycle operation admitted and queued")
if err != nil {
return domain.PluginLifecycleResult{}, err
}
installation.AuditEventID = auditID
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
return domain.PluginLifecycleResult{}, err
}
if errors.Is(getErr, repo.ErrNotFound) {
err = svc.store.PluginLifecycles().Create(installation)
} else {
err = svc.store.PluginLifecycles().Update(installation)
}
if err != nil {
return domain.PluginLifecycleResult{}, err
}
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Decision: decision, Status: "queued"}), nil
}
func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return nil, err
}
previews, err := svc.store.AIConfigDiffs().List(filter)
if err != nil {
return nil, err
}
visible := make([]domain.AIConfigDiffPreview, 0, len(previews))
for _, preview := range previews {
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
if err == nil && canAccessServer(user, instance) {
visible = append(visible, preview)
}
}
sort.Slice(visible, func(i, j int) bool { return visible[i].CreatedAt.After(visible[j].CreatedAt) })
return domain.CopyAIConfigDiffPreviews(visible), nil
}
func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error) {
if err := validator.ValidateAIConfigDiffApprovalRequest(request); err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
svc.productionMu.Lock()
defer svc.productionMu.Unlock()
preview, err := svc.store.AIConfigDiffs().Get(request.DiffID)
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
if !canAccessServer(user, instance) || (!isPlatformAdmin(user) && preview.CreatedBy != user.ID) {
return domain.AIConfigDiffApprovalResult{}, ErrForbidden
}
if preview.State == domain.AIConfigDiffStateApproved {
if preview.ApprovalIdempotencyKey != request.IdempotencyKey {
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is already approved with another idempotency key")
}
job, err := svc.store.Jobs().Get(preview.JobID)
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
dispatch := domain.ServerConfigWriteDispatch{Job: job, Status: "queued"}
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
}
if preview.State != domain.AIConfigDiffStatePending {
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is not pending approval")
}
if !preview.ExpiresAt.After(svc.now()) {
preview.State = domain.AIConfigDiffStateExpired
preview.UpdatedAt = svc.now()
_ = svc.store.AIConfigDiffs().Update(preview)
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff has expired")
}
dispatch, err := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{ServerInstanceID: preview.ServerInstanceID, ExpectedConfigVersion: preview.ConfigVersion, ExpectedChecksum: preview.CurrentConfigChecksum, Key: preview.Key, ProposedContent: preview.ProposedConfig, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
stamp := svc.now()
preview.State = domain.AIConfigDiffStateApproved
preview.ApprovedBy = user.ID
preview.ApprovedAt = stamp
preview.ApprovalIdempotencyKey = request.IdempotencyKey
preview.JobID = dispatch.Job.ID
preview.UpdatedAt = stamp
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
if _, err := svc.recordAuditEventWithID(user.ID, "ai.config-diff.approve", "ai-config-diff", preview.ID, domain.AuditResultQueued, "approved reviewed AI config diff and queued one config write job"); err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
}
func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time.Time) error {
if job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
return nil
}
svc.productionMu.Lock()
defer svc.productionMu.Unlock()
installation, err := svc.store.PluginLifecycles().Get(pluginLifecycleInstallationID(job.ExecutionInput.PluginID, job.ServerInstanceID))
if err != nil {
return err
}
if installation.JobID != job.ID {
return nil
}
if job.State == domain.JobStateSucceeded {
applyPluginLifecycleSuccess(&installation)
installation.FailureReason = ""
if err := svc.resolveAlertForSource("plugin-lifecycle", installation.ID, "plugin.lifecycle.failed", "run:"+job.RunEndpointID, "plugin lifecycle job completed", installation.AuditEventID); err != nil {
return err
}
} else {
installation.CurrentState = domain.PluginLifecycleStateFailed
installation.FailureReason = "plugin lifecycle job did not complete successfully"
alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.failed", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle operation failed", Message: installation.FailureReason, Retryable: true, LastJobID: job.ID, LastAuditEventID: installation.AuditEventID})
if err != nil {
return err
}
installation.AlertID = alert.ID
}
installation.UpdatedAt = stamp
return svc.store.PluginLifecycles().Update(installation)
}
func (svc *CoreService) persistAIConfigDiff(actorID string, provider domain.AIProvider, request domain.AIInvocationRequest, result domain.AIProviderInvocationResult) (domain.AIConfigDiffPreview, error) {
config, err := svc.getServerConfigForUser(actorID, request.ServerInstanceID)
if err != nil {
return domain.AIConfigDiffPreview{}, err
}
stamp := svc.now()
preview := domain.AIConfigDiffPreview{ID: aiConfigDiffID(request.RequestID, request.ServerInstanceID), RequestID: request.RequestID, CreatedBy: actorID, ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, ProviderID: provider.ID, Model: result.Usage.Model, Key: config.Key, ConfigVersion: config.ConfigVersion, CurrentConfigChecksum: config.Checksum, ProposedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch", State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(aiConfigDiffTTL), CreatedAt: stamp, UpdatedAt: stamp}
if err := validator.ValidateAIConfigDiffPreview(preview); err != nil {
return domain.AIConfigDiffPreview{}, err
}
if err := svc.store.AIConfigDiffs().Create(preview); err != nil {
if !errors.Is(err, repo.ErrDuplicate) {
return domain.AIConfigDiffPreview{}, err
}
existing, getErr := svc.store.AIConfigDiffs().Get(preview.ID)
if getErr != nil {
return domain.AIConfigDiffPreview{}, getErr
}
if existing.CreatedBy != actorID || existing.ServerInstanceID != request.ServerInstanceID || existing.PluginID != request.PluginID || existing.ProposedConfig != result.SuggestedConfig {
return domain.AIConfigDiffPreview{}, validationError("requestId is already used for a different AI config recommendation")
}
return existing, nil
}
return preview, nil
}
func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string) (domain.ServerConfig, error) {
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return domain.ServerConfig{}, err
}
user, err := svc.store.Users().Get(userID)
if err != nil {
return domain.ServerConfig{}, err
}
if !canAccessServer(user, instance) {
return domain.ServerConfig{}, ErrForbidden
}
config := domain.ServerConfig{ServerInstanceID: instance.ID, ConfigVersion: instance.ConfigVersion, Format: "properties", Key: instance.ConfigKey, Content: instance.ConfigContent, Checksum: instance.ConfigChecksum, Source: "platform-derived", UpdatedAt: instance.ConfigUpdatedAt}
if config.ConfigVersion <= 0 {
config.ConfigVersion = 1
}
if config.Key == "" {
config.Key = "server.properties"
}
if config.Content == "" {
config.Content = buildLogicalServerConfig(instance)
}
if config.Checksum == "" {
config.Checksum = validator.BytesChecksum([]byte(config.Content))
}
if config.UpdatedAt.IsZero() {
config.UpdatedAt = svc.now()
}
return config, validator.ValidateServerConfig(config)
}
func (svc *CoreService) authorizeCapacityRequest(user domain.User, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionRequest, error) {
if request.ServerInstanceID != "" {
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
if err != nil {
return request, err
}
if !canAccessServer(user, instance) {
return request, ErrForbidden
}
if request.RunEndpointID != "" && request.RunEndpointID != instance.RunEndpointID {
return request, validationError("runEndpointId must match server instance")
}
request.RunEndpointID = instance.RunEndpointID
return request, nil
}
if request.RunEndpointID == "" {
return request, validationError("serverInstanceId or runEndpointId is required")
}
if isPlatformAdmin(user) {
return request, nil
}
visible, err := svc.visibleEndpointIDs(user)
if err != nil {
return request, err
}
if _, ok := visible[request.RunEndpointID]; !ok {
return request, ErrForbidden
}
return request, nil
}
func (svc *CoreService) visibleEndpointIDs(user domain.User) (map[string]struct{}, error) {
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{})
if err != nil {
return nil, err
}
ids := map[string]struct{}{}
for _, instance := range instances {
if isPlatformAdmin(user) || canAccessServer(user, instance) {
ids[instance.RunEndpointID] = struct{}{}
}
}
return ids, nil
}
func (svc *CoreService) capacityJobCounts(endpointID string) (int, int, error) {
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpointID})
if err != nil {
return 0, 0, err
}
running, queued := 0, 0
for _, job := range jobs {
switch job.State {
case domain.JobStateAccepted, domain.JobStateRunning:
running++
case domain.JobStateQueued, domain.JobStateRetrying:
queued++
}
}
return running, queued, nil
}
func (svc *CoreService) capacityProjection(endpoint domain.RunEndpoint, durableRunning, durableQueued int) domain.EndpointCapacityProjection {
running := maxInt(endpoint.Capacity.RunningJobs, durableRunning)
queued := maxInt(endpoint.Capacity.QueuedJobs, durableQueued)
projection := domain.EndpointCapacityProjection{RunEndpointID: endpoint.ID, DisplayName: endpoint.DisplayName, Status: endpoint.Status, Capabilities: endpoint.Capabilities, MaxJobs: endpoint.Capacity.MaxJobs, RunningJobs: running, QueuedJobs: queued, LogBacklogBatches: endpoint.Capacity.LogBacklogBatches, ArtifactBacklogChunks: endpoint.Capacity.ArtifactBacklogChunks, Summary: safeBridgeReason(endpoint.Capacity.Summary), LastHeartbeatAt: endpoint.LastHeartbeatAt}
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointOffline)
}
if endpoint.LastHeartbeatAt.IsZero() || svc.now().Sub(endpoint.LastHeartbeatAt) > capacityHeartbeatStaleAfter {
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointStale)
}
if projection.MaxJobs <= 0 || running >= projection.MaxJobs {
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureJobLimit)
}
queueLimit := maxInt(4, projection.MaxJobs*2)
if queued >= queueLimit {
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureQueueLimit)
}
if projection.LogBacklogBatches >= capacityLogBacklogLimit || projection.ArtifactBacklogChunks >= capacityArtifactBacklogLimit || len(endpoint.Capacity.PressureCodes) > 0 {
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureBacklog)
}
return projection
}
func (svc *CoreService) upsertAlert(candidate domain.AlertRecord) (domain.AlertRecord, error) {
stamp := svc.now()
candidate.ID = alertIDForSource(candidate.SourceKind, candidate.SourceID, candidate.RuleKey)
existing, err := svc.store.Alerts().Get(candidate.ID)
if err == nil {
existing.Severity = candidate.Severity
existing.State = domain.AlertStateActive
existing.Title = candidate.Title
existing.Message = safeBridgeReason(candidate.Message)
existing.OccurrenceCount++
existing.Retryable = candidate.Retryable
existing.RetryAfterSeconds = candidate.RetryAfterSeconds
existing.LastJobID = candidate.LastJobID
existing.LastAuditEventID = candidate.LastAuditEventID
existing.LastSeenAt = stamp
existing.ResolvedBy = ""
existing.ResolvedAt = time.Time{}
existing.ResolutionNote = ""
existing.UpdatedAt = stamp
if err := validator.ValidateAlertRecord(existing); err != nil {
return domain.AlertRecord{}, err
}
if err := svc.store.Alerts().Update(existing); err != nil {
return domain.AlertRecord{}, err
}
return existing, nil
}
if !errors.Is(err, repo.ErrNotFound) {
return domain.AlertRecord{}, err
}
candidate.State = domain.AlertStateActive
candidate.Message = safeBridgeReason(candidate.Message)
candidate.OccurrenceCount = 1
candidate.LastSeenAt = stamp
candidate.CreatedAt = stamp
candidate.UpdatedAt = stamp
if err := validator.ValidateAlertRecord(candidate); err != nil {
return domain.AlertRecord{}, err
}
if err := svc.store.Alerts().Create(candidate); err != nil {
return domain.AlertRecord{}, err
}
return candidate, nil
}
func (svc *CoreService) resolveAlertForSource(sourceKind, sourceID, ruleKey, actorID, note, auditID string) error {
alert, err := svc.store.Alerts().Get(alertIDForSource(sourceKind, sourceID, ruleKey))
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil {
return err
}
if alert.State == domain.AlertStateResolved {
return nil
}
stamp := svc.now()
alert.State = domain.AlertStateResolved
alert.ResolvedBy = actorID
alert.ResolvedAt = stamp
alert.ResolutionNote = note
alert.LastAuditEventID = auditID
alert.UpdatedAt = stamp
return svc.store.Alerts().Update(alert)
}
func (svc *CoreService) canAccessAlert(user domain.User, alert domain.AlertRecord) bool {
if isPlatformAdmin(user) {
return true
}
switch alert.SourceKind {
case "server-instance":
instance, err := svc.store.ServerInstances().Get(alert.SourceID)
return err == nil && canAccessServer(user, instance)
case "run-endpoint":
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: alert.SourceID})
if err != nil {
return false
}
for _, instance := range instances {
if canAccessServer(user, instance) {
return true
}
}
case "plugin-lifecycle":
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
if err != nil {
return false
}
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
return err == nil && canAccessServer(user, instance)
case "ai-config-diff":
preview, err := svc.store.AIConfigDiffs().Get(alert.SourceID)
if err != nil {
return false
}
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
return err == nil && canAccessServer(user, instance)
}
return false
}
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
svc.productionMu.Lock()
defer svc.productionMu.Unlock()
stamp := svc.now()
installation := domain.PluginLifecycleInstallation{ID: pluginLifecycleInstallationID(plugin.ID, instance.ID), PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStateFailed, LastOperation: request.Operation, Compatibility: "incompatible", DependencyState: domain.DependencyStateUnknown, FailureReason: safeBridgeReason(reason), IdempotencyKey: request.IdempotencyKey, CreatedAt: stamp, UpdatedAt: stamp}
if existing, err := svc.store.PluginLifecycles().Get(installation.ID); err == nil {
installation.CreatedAt = existing.CreatedAt
}
return svc.pluginLifecycleDeniedLocked(actorID, installation, request, reason)
}
func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
stamp := svc.now()
auditID, err := svc.recordAuditEventWithID(actorID, "plugin.lifecycle.denied", "plugin-lifecycle", installation.ID, domain.AuditResultDenied, reason)
if err != nil {
return domain.PluginLifecycleResult{}, err
}
installation.CurrentState = domain.PluginLifecycleStateFailed
installation.LastOperation = request.Operation
installation.TargetVersion = request.TargetVersion
installation.FailureReason = safeBridgeReason(reason)
installation.AuditEventID = auditID
installation.UpdatedAt = stamp
alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.compatibility", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle compatibility check failed", Message: installation.FailureReason, Retryable: true, LastAuditEventID: auditID})
if err != nil {
return domain.PluginLifecycleResult{}, err
}
installation.AlertID = alert.ID
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
return domain.PluginLifecycleResult{}, err
}
if _, err := svc.store.PluginLifecycles().Get(installation.ID); errors.Is(err, repo.ErrNotFound) {
err = svc.store.PluginLifecycles().Create(installation)
} else if err == nil {
err = svc.store.PluginLifecycles().Update(installation)
}
if err != nil {
return domain.PluginLifecycleResult{}, err
}
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Alert: &alert, Status: "denied"}), nil
}
func pluginLifecycleDispatchMetadata(plugin domain.GamePlugin, operation domain.PluginLifecycleOperation) (string, string, error) {
switch operation {
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback:
if plugin.LifecycleActions.Install == "" {
return "", "", validationError("plugin install action is not declared")
}
return domain.LifecycleCapabilityInstall, plugin.LifecycleActions.Install, nil
case domain.PluginLifecycleOperationEnable:
if plugin.LifecycleActions.Start == "" {
return "", "", validationError("plugin start action is not declared")
}
return domain.LifecycleCapabilityStart, plugin.LifecycleActions.Start, nil
case domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationRetire:
if plugin.LifecycleActions.Stop == "" {
return "", "", validationError("plugin stop action is not declared")
}
return domain.LifecycleCapabilityStop, plugin.LifecycleActions.Stop, nil
case domain.PluginLifecycleOperationDependencyCheck:
return domain.JobCapabilityDependenciesCheck, "dependencies/plugin", nil
default:
return "", "", validationError("plugin lifecycle operation is invalid")
}
}
func validatePluginLifecycleTransition(installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest) error {
switch request.Operation {
case domain.PluginLifecycleOperationInstall:
if installation.CurrentState == domain.PluginLifecycleStateInstalled || installation.CurrentState == domain.PluginLifecycleStateEnabled || installation.CurrentState == domain.PluginLifecycleStateDisabled {
return validationError("plugin is already installed")
}
case domain.PluginLifecycleOperationEnable:
if installation.CurrentState != domain.PluginLifecycleStateInstalled && installation.CurrentState != domain.PluginLifecycleStateDisabled {
return validationError("plugin must be installed or disabled before enable")
}
case domain.PluginLifecycleOperationDisable:
if installation.CurrentState != domain.PluginLifecycleStateEnabled {
return validationError("plugin must be enabled before disable")
}
case domain.PluginLifecycleOperationUpgrade:
if installation.CurrentVersion == "" || request.TargetVersion == installation.CurrentVersion {
return validationError("upgrade requires a different target version")
}
case domain.PluginLifecycleOperationRollback:
if installation.PreviousVersion == "" {
return validationError("rollback requires a retained previous version")
}
case domain.PluginLifecycleOperationRetire:
if installation.CurrentState == domain.PluginLifecycleStateRetired {
return validationError("plugin is already retired")
}
}
return nil
}
func desiredPluginLifecycleState(operation domain.PluginLifecycleOperation, installation domain.PluginLifecycleInstallation) domain.PluginLifecycleState {
switch operation {
case domain.PluginLifecycleOperationInstall:
return domain.PluginLifecycleStateInstalled
case domain.PluginLifecycleOperationEnable:
return domain.PluginLifecycleStateEnabled
case domain.PluginLifecycleOperationDisable:
return domain.PluginLifecycleStateDisabled
case domain.PluginLifecycleOperationRetire:
return domain.PluginLifecycleStateRetired
default:
return installation.DesiredState
}
}
func dispatchedPluginLifecycleState(operation domain.PluginLifecycleOperation, current domain.PluginLifecycleState) domain.PluginLifecycleState {
switch operation {
case domain.PluginLifecycleOperationUpgrade:
return domain.PluginLifecycleStateUpgrading
case domain.PluginLifecycleOperationRollback:
return domain.PluginLifecycleStateRollingBack
case domain.PluginLifecycleOperationInstall:
return domain.PluginLifecycleStatePending
default:
return current
}
}
func applyPluginLifecycleSuccess(installation *domain.PluginLifecycleInstallation) {
switch installation.LastOperation {
case domain.PluginLifecycleOperationInstall:
installation.CurrentVersion = installation.TargetVersion
installation.CurrentState = domain.PluginLifecycleStateInstalled
case domain.PluginLifecycleOperationEnable:
installation.CurrentState = domain.PluginLifecycleStateEnabled
case domain.PluginLifecycleOperationDisable:
installation.CurrentState = domain.PluginLifecycleStateDisabled
case domain.PluginLifecycleOperationUpgrade:
installation.CurrentVersion = installation.TargetVersion
installation.CurrentState = installation.DesiredState
if installation.CurrentState != domain.PluginLifecycleStateEnabled && installation.CurrentState != domain.PluginLifecycleStateDisabled {
installation.CurrentState = domain.PluginLifecycleStateInstalled
}
case domain.PluginLifecycleOperationRollback:
current := installation.CurrentVersion
installation.CurrentVersion = installation.PreviousVersion
installation.PreviousVersion = current
installation.TargetVersion = installation.CurrentVersion
installation.CurrentState = domain.PluginLifecycleStateInstalled
case domain.PluginLifecycleOperationRetire:
installation.CurrentState = domain.PluginLifecycleStateRetired
case domain.PluginLifecycleOperationDependencyCheck:
installation.DependencyState = domain.DependencyStatePresent
}
}
func alertIDForSource(sourceKind, sourceID, ruleKey string) string {
sum := sha256.Sum256([]byte(sourceKind + "\x00" + sourceID + "\x00" + ruleKey))
return "alert-" + hex.EncodeToString(sum[:12])
}
func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string {
sum := sha256.Sum256([]byte(pluginID + "\x00" + serverInstanceID))
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
}
func aiConfigDiffID(requestID, serverInstanceID string) string {
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
return "ai-config-diff-" + hex.EncodeToString(sum[:12])
}
func appendCapacityPressure(codes []domain.CapacityPressureCode, code domain.CapacityPressureCode) []domain.CapacityPressureCode {
if !containsCapacityPressure(codes, code) {
return append(codes, code)
}
return codes
}
func containsCapacityPressure(codes []domain.CapacityPressureCode, target domain.CapacityPressureCode) bool {
for _, code := range codes {
if code == target {
return true
}
}
return false
}
func firstCapacityCapability(capabilities []string) string {
for _, capability := range capabilities {
if strings.TrimSpace(capability) != "" {
return capability
}
}
return "control.heartbeat"
}
func defaultAlertNote(note, fallback string) string {
if strings.TrimSpace(note) == "" {
return fallback
}
return safeBridgeReason(note)
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+182
View File
@@ -0,0 +1,182 @@
package service
import (
"errors"
"strings"
"testing"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestProductionCapacityCreatesDurableAlertAndSupportsClosure(t *testing.T) {
svc, session, instance := newProductionOpsFixture(t)
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
t.Fatalf("get endpoint: %v", err)
}
endpoint.Capacity.RunningJobs = endpoint.Capacity.MaxJobs
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update endpoint pressure: %v", err)
}
decision, err := svc.CheckCapacityAdmissionForSession(session, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityInstall, IdempotencyKey: "capacity-pressure"})
if err != nil {
t.Fatalf("check capacity: %v", err)
}
if decision.Accepted || decision.State != domain.CapacityAdmissionDeferred || decision.AlertID == "" || decision.AuditEventID == "" {
t.Fatalf("expected durable deferred decision, got %+v", decision)
}
alerts, err := svc.ListAlertsForSession(session, domain.AlertFilter{State: domain.AlertStateActive})
if err != nil || len(alerts) != 1 || alerts[0].OccurrenceCount != 1 {
t.Fatalf("expected one active alert, got %+v err=%v", alerts, err)
}
acknowledged, err := svc.AcknowledgeAlertForSession(session, domain.AlertAcknowledgeRequest{AlertID: decision.AlertID, Note: "operator reviewing queue pressure"})
if err != nil || acknowledged.State != domain.AlertStateAcknowledged || acknowledged.AcknowledgedBy == "" {
t.Fatalf("acknowledge alert: %+v err=%v", acknowledged, err)
}
resolved, err := svc.ResolveAlertForSession(session, domain.AlertResolveRequest{AlertID: decision.AlertID, Note: "capacity policy reviewed"})
if err != nil || resolved.State != domain.AlertStateResolved || resolved.ResolvedBy == "" {
t.Fatalf("resolve alert: %+v err=%v", resolved, err)
}
}
func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
svc, session, instance := newProductionOpsFixture(t)
request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"}
first, err := svc.RunPluginLifecycleForSession(session, request)
if err != nil {
t.Fatalf("dispatch plugin install: %v", err)
}
second, err := svc.RunPluginLifecycleForSession(session, request)
if err != nil {
t.Fatalf("repeat plugin install: %v", err)
}
if first.Job.ID == "" || second.Job.ID != first.Job.ID {
t.Fatalf("expected one idempotent job, got first=%+v second=%+v", first.Job, second.Job)
}
drift := request
drift.TargetVersion = "1.1.0"
if _, err := svc.RunPluginLifecycleForSession(session, drift); err == nil || !strings.Contains(err.Error(), "idempotencyKey") {
t.Fatalf("expected immutable input conflict, got %v", err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 1 {
t.Fatalf("expected exactly one lifecycle job, got %+v err=%v", jobs, err)
}
}
func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) {
svc, session, instance := newProductionOpsFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.lifecycle")
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionPluginLifecycle))
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
Key: "operations", Title: "Operations", Path: "/operations",
Permissions: []string{"server.lifecycle"}, BridgeActions: []string{string(domain.PluginBridgeActionPluginLifecycle)},
})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin bridge declaration: %v", err)
}
response, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
RequestID: "bridge-plugin-install", PluginID: plugin.ID, RouteKey: "operations",
ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionPluginLifecycle,
Payload: map[string]string{"operation": "install", "targetVersion": plugin.Version, "idempotencyKey": "bridge-plugin-install-v1", "confirmed": "false"},
})
if err != nil {
t.Fatalf("execute lifecycle bridge: %v", err)
}
if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" || response.Result["admissionState"] != string(domain.CapacityAdmissionAccepted) {
t.Fatalf("expected Platform-governed lifecycle job, got %+v", response)
}
serialized := strings.ToLower(strings.Join([]string{
response.Result["jobId"], response.Result["installationId"], response.Result["currentState"],
response.Result["desiredState"], response.Result["alertId"], response.Result["auditEventId"],
response.Result["admissionState"], response.Result["admissionReason"],
}, " "))
for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} {
if strings.Contains(serialized, forbidden) {
t.Fatalf("bridge lifecycle result exposed forbidden fragment %q: %s", forbidden, serialized)
}
}
}
func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) {
svc, session, instance := newProductionOpsFixture(t)
provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"})
if err != nil {
t.Fatalf("create provider: %v", err)
}
response, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-1", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
if err != nil {
t.Fatalf("invoke AI: %v", err)
}
if response.ConfigRecommendation == nil || response.ConfigRecommendation.DiffID == "" {
t.Fatalf("expected persisted config recommendation, got %+v", response)
}
jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if len(jobs) != 0 {
t.Fatalf("AI recommendation must not dispatch before approval: %+v", jobs)
}
approved, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
if err != nil {
t.Fatalf("approve AI diff: %v", err)
}
if approved.Preview.State != domain.AIConfigDiffStateApproved || approved.Dispatch.Job.ID == "" {
t.Fatalf("expected approved diff and queued job, got %+v", approved)
}
repeated, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
if err != nil || repeated.Dispatch.Job.ID != approved.Dispatch.Job.ID {
t.Fatalf("repeat approval must return original job: %+v err=%v", repeated, err)
}
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if len(jobs) != 1 {
t.Fatalf("approval must dispatch exactly one job, got %+v", jobs)
}
staleResponse, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-stale", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
if err != nil {
t.Fatalf("invoke stale AI candidate: %v", err)
}
stored, err := svc.store.ServerInstances().Get(instance.ID)
if err != nil {
t.Fatalf("get server: %v", err)
}
stored.ConfigVersion++
if err := svc.store.ServerInstances().Update(stored); err != nil {
t.Fatalf("advance config revision: %v", err)
}
if _, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: staleResponse.ConfigRecommendation.DiffID, IdempotencyKey: "approve-stale"}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
t.Fatalf("expected stale revision rejection, got %v", err)
}
}
func newProductionOpsFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
t.Helper()
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.SupportedOS = []string{"linux"}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin platform: %v", err)
}
endpoint.Platform = "linux"
endpoint.Architecture = "amd64"
endpoint.LastHeartbeatAt = fixedTime
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update endpoint metadata: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "production-owner", DisplayName: "Production Owner", Email: "production-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "production-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Production Server", State: domain.ServerInstanceStateReady})
if err != nil {
t.Fatalf("create server: %v", err)
}
binding := domain.RuntimeBinding{ID: "runtime-binding-" + instance.ID, ServerInstanceID: instance.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}
if err := svc.store.RuntimeBindings().Create(binding); err != nil && !errors.Is(err, repo.ErrDuplicate) {
t.Fatalf("create runtime binding: %v", err)
}
return svc, session, instance
}
+6 -2
View File
@@ -82,17 +82,21 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
if timeout > selected.TimeoutSeconds || attempts > selected.MaxAttempts {
return domain.RemoteAdapterResult{}, validationError("remote adapter timeout or retry exceeds declaration")
}
inputRef := request.InputRef
if inputRef == "" {
inputRef = fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey)
}
job := domain.Job{
ID: jobIDFromParts("job-remote-adapter", instance.ID, request.IdempotencyKey),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: request.Capability,
TargetKey: request.TargetKey,
InputRef: fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey),
InputRef: inputRef,
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Message: "scoped remote adapter queued"},
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: attempts, InitialBackoffSeconds: 2, MaxBackoffSeconds: 30},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout, Inputs: domain.CopyStringMap(request.Inputs)},
}
created, err := svc.CreateJob(job)
if err != nil {
+88
View File
@@ -0,0 +1,88 @@
package service
import (
"encoding/json"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
plugin.Permissions.RemoteAccess = true
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
plugin.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}}
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "player-lookup", Kind: "sqlite", TargetKey: "scum-db.player-lookup", Capabilities: []string{capability}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
endpoint.Capabilities = append(endpoint.Capabilities, capability)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatal(err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-remote-owner", DisplayName: "Remote Owner", Email: "remote-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-remote-input", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Remote Input"})
if err != nil {
t.Fatal(err)
}
inputs := map[string]string{"playerId": "steam-123", "limit": "25"}
result, err := svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "player-lookup", TargetKey: "scum-db.player-lookup", Capability: capability, IdempotencyKey: "lookup-1", InputRef: "input://scum-db/player-lookup/lookup-1", Inputs: inputs})
if err != nil {
t.Fatalf("request remote adapter: %v", err)
}
inputs["playerId"] = "mutated"
job, err := svc.store.Jobs().Get(result.RequestID)
if err != nil {
t.Fatal(err)
}
if job.InputRef != "input://scum-db/player-lookup/lookup-1" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["limit"] != "25" {
t.Fatalf("typed inputs were not propagated: %#v", job)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability)
helloRequest.CapabilityReport.Fingerprint = "cap-remote-inputs"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register Run hello: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil {
t.Fatalf("claim remote adapter job: %v", err)
}
if !claim.HasJob || claim.Job == nil || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.Inputs["playerId"] != "steam-123" || claim.Job.ExecutionInput.Inputs["limit"] != "25" {
t.Fatalf("typed inputs were not propagated to real Run claim: %#v", claim.Job)
}
response := dto.RunJobAssignmentFromDomain(*claim.Job)
payload, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal Run assignment response: %v", err)
}
var wire struct {
ExecutionInput struct {
Inputs map[string]string `json:"inputs"`
} `json:"executionInput"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
t.Fatalf("unmarshal Run assignment response: %v", err)
}
if wire.ExecutionInput.Inputs["playerId"] != "steam-123" || wire.ExecutionInput.Inputs["limit"] != "25" {
t.Fatalf("typed inputs were not preserved in Run assignment JSON: %s", payload)
}
wire.ExecutionInput.Inputs["playerId"] = "wire-mutated"
if claim.Job.ExecutionInput.Inputs["playerId"] != "steam-123" {
t.Fatal("Run assignment DTO aliases domain remote inputs")
}
claim.Job.ExecutionInput.Inputs["playerId"] = "assignment-mutated"
stored, err := svc.store.Jobs().Get(result.RequestID)
if err != nil {
t.Fatalf("get claimed remote adapter job: %v", err)
}
if stored.ExecutionInput.Inputs["playerId"] != "steam-123" {
t.Fatal("real Run claim aliases persisted remote inputs")
}
}
+205 -6
View File
@@ -23,6 +23,28 @@ var (
ErrForbidden = errors.New("forbidden")
)
type ForbiddenError struct {
Reason string
}
func (err ForbiddenError) Error() string {
if strings.TrimSpace(err.Reason) == "" {
return ErrForbidden.Error()
}
return ErrForbidden.Error() + ": " + err.Reason
}
func (err ForbiddenError) Is(target error) bool {
return target == ErrForbidden
}
func forbiddenError(reason string) error {
if strings.TrimSpace(reason) == "" {
return ErrForbidden
}
return ForbiddenError{Reason: reason}
}
type Core interface {
CreateUser(domain.User) (domain.User, error)
UpdateUser(string, domain.User) (domain.User, error)
@@ -79,6 +101,16 @@ type Core interface {
ArchiveServerInstanceForSession(string, string) (domain.ServerInstance, error)
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error)
CheckCapacityAdmissionForSession(string, domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error)
ListAlertsForSession(string, domain.AlertFilter) ([]domain.AlertRecord, error)
AcknowledgeAlertForSession(string, domain.AlertAcknowledgeRequest) (domain.AlertRecord, error)
ResolveAlertForSession(string, domain.AlertResolveRequest) (domain.AlertRecord, error)
RetryAlertForSession(string, domain.AlertRetryRequest) (domain.AlertRetryResult, error)
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error)
ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
ApproveAIConfigDiffForSession(string, domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error)
IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error)
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
@@ -137,6 +169,17 @@ type Core interface {
RegisterClientManager(domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error)
AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error)
ReconcileClientManagerLifecycle() error
QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error)
ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error)
AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error)
CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error)
CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error)
UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error)
ReconcileGameClientBridgeCommands() error
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
@@ -169,6 +212,8 @@ type CoreService struct {
runSessions map[string]domain.RunControlSession
runSessionSeq uint64
jobMu sync.Mutex
bridgeMu sync.Mutex
bridgeSeq uint64
logStore LogBodyStore
artifactStore ArtifactBodyStore
artifactMu sync.Mutex
@@ -177,6 +222,7 @@ type CoreService struct {
artifactTransferSeq uint64
auditMu sync.Mutex
auditSeq uint64
productionMu sync.Mutex
aiProviderClient AIProviderClient
secretEnvelope SecretEnvelope
}
@@ -237,6 +283,9 @@ func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, ar
if err := service.ReconcileClientManagerLifecycle(); err != nil {
return nil, err
}
if err := service.ReconcileGameClientBridgeCommands(); err != nil {
return nil, err
}
return service, nil
}
@@ -538,13 +587,13 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
result := domain.AIProviderTestResult{
ProviderID: provider.ID,
Mode: "metadata",
Mode: "provider",
Success: true,
Message: "metadata validation passed",
Message: "provider invocation passed",
}
if err := validator.ValidateAIProvider(provider); err != nil {
result.Success = false
result.Message = "metadata validation failed"
result.Message = "provider validation failed"
var validationErr validator.ValidationError
if errors.As(err, &validationErr) {
result.Violations = append(result.Violations, validationErr.Violations...)
@@ -554,9 +603,35 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
}
if provider.Status != domain.AIProviderStatusActive {
result.Success = false
result.Message = "metadata validation failed"
result.Message = "provider validation failed"
result.Violations = append(result.Violations, "provider must be active")
}
if !result.Success {
return domain.CopyAIProviderTestResult(result), nil
}
_, invokeErr := svc.aiProviderClient.Invoke(provider, domain.AIInvocationRequest{RequestID: "provider-test-" + provider.ID, Purpose: "provider.health", Prompt: "Return a short health acknowledgement.", Model: provider.DefaultModel})
if invokeErr != nil {
result.Success = false
result.Message = "provider invocation failed safely"
result.Violations = []string{"provider invocation failed safely"}
auditID, auditErr := svc.recordAuditEventWithID("platform", "ai.provider.test.failed", "ai-provider", provider.ID, domain.AuditResultFailed, result.Message)
if auditErr != nil {
return domain.AIProviderTestResult{}, auditErr
}
svc.productionMu.Lock()
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider health check failed", Message: result.Message, Retryable: false, LastAuditEventID: auditID})
svc.productionMu.Unlock()
if alertErr != nil {
return domain.AIProviderTestResult{}, alertErr
}
} else {
svc.productionMu.Lock()
resolveErr := svc.resolveAlertForSource("ai-provider", provider.ID, "ai.provider.failed", "platform", "AI provider health check passed", "")
svc.productionMu.Unlock()
if resolveErr != nil {
return domain.AIProviderTestResult{}, resolveErr
}
}
return domain.CopyAIProviderTestResult(result), nil
}
@@ -584,6 +659,7 @@ func (svc *CoreService) CreateGamePlugin(plugin domain.GamePlugin) (domain.GameP
if plugin.Status == "" {
plugin.Status = domain.GamePluginStatusInstalled
}
plugin.ProductionLifecycle = normalizedProductionLifecycle(plugin.ProductionLifecycle)
if err := validator.ValidateGamePlugin(plugin); err != nil {
return domain.GamePlugin{}, err
}
@@ -621,8 +697,10 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
Pages: manifest.Pages,
Tags: manifest.Tags,
AIPurposes: manifest.AI.Purposes,
ProductionLifecycle: manifest.ProductionLifecycle,
RemoteAccess: manifest.RemoteAccess,
RuntimeProfiles: manifest.RuntimeProfiles,
GameClientBridge: manifest.GameClientBridge,
Status: domain.GamePluginStatusInstalled,
}
}
@@ -714,6 +792,8 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma
base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload)
case domain.PluginBridgeActionClientManager:
base = svc.executeBridgeClientManager(sessionID, base, request)
case domain.PluginBridgeActionPluginLifecycle:
base = svc.executeBridgePluginLifecycle(sessionID, base, request)
case domain.PluginBridgeActionArtifactsOpen:
base = svc.executeBridgeArtifactOpen(sessionID, base, request)
case domain.PluginBridgeActionAIInvoke:
@@ -775,7 +855,6 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
RouteKey: request.RouteKey,
ServerInstanceID: request.ServerInstanceID,
Purpose: request.AIPurpose,
ProviderID: request.Payload["providerId"],
Model: request.Payload["model"],
Prompt: defaultBridgeValue(request.Payload["prompt"], "Review the current server context and provide a safe recommendation."),
CurrentConfig: request.Payload["currentConfig"],
@@ -795,6 +874,9 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
if response.ConfigRecommendation != nil {
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
base.Result["diffId"] = response.ConfigRecommendation.DiffID
base.Result["key"] = response.ConfigRecommendation.Key
base.Result["expiresAt"] = response.ConfigRecommendation.ExpiresAt
}
if response.Error != nil {
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
@@ -802,6 +884,22 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
return base
}
func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
confirmed, err := strconv.ParseBool(defaultBridgeValue(request.Payload["confirmed"], "false"))
if err != nil {
base.Status = "error"
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "confirmed must be true or false"}
return base
}
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: request.PluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Payload["operation"]), TargetVersion: request.Payload["targetVersion"], IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID), Confirmed: confirmed})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = result.Status
base.Result = map[string]string{"installationId": result.Installation.ID, "currentState": string(result.Installation.CurrentState), "desiredState": string(result.Installation.DesiredState), "jobId": result.Job.ID, "alertId": result.Installation.AlertID, "auditEventId": result.Installation.AuditEventID, "admissionState": string(result.Decision.State), "admissionReason": result.Decision.Reason}
return base
}
func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
capability := strings.TrimSpace(payload["capability"])
if capability == "" {
@@ -951,7 +1049,53 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
}
timeoutSeconds, _ := strconv.Atoi(payload["timeoutSeconds"])
maxAttempts, _ := strconv.Atoi(payload["maxAttempts"])
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID)})
inputs := map[string]string{}
for key, value := range payload {
if strings.HasPrefix(key, "input.") {
inputs[strings.TrimPrefix(key, "input.")] = value
}
}
if capability == domain.JobCapabilityRemoteRunDBSQLiteQuery {
templateKey := strings.TrimSpace(inputs["templateKey"])
if templateKey == "" {
base.Status = "error"
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "input.templateKey is required for sqlite query requests"}
return base
}
template, reason := findBridgeQueryTemplate(plugin, base.RouteKey, templateKey)
if reason != "" {
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "query_template_denied", Message: reason}
return base
}
if template.Engine != "sqlite" || template.TransportKey != declarationKey || template.TargetKey != payload["targetKey"] {
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "query_template_denied", Message: "query template transport or target is not approved"}
return base
}
if timeoutSeconds == 0 {
timeoutSeconds = template.TimeoutSeconds
} else if timeoutSeconds > template.TimeoutSeconds {
base.Status = "error"
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "query template timeout limit exceeded"}
return base
}
maxRows := template.MaxRows
if requestedRows, ok := inputs["maxRows"]; ok && strings.TrimSpace(requestedRows) != "" {
parsedRows, parseErr := strconv.Atoi(requestedRows)
if parseErr != nil || parsedRows <= 0 {
base.Status = "error"
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "input.maxRows must be a positive integer"}
return base
}
if parsedRows < maxRows {
maxRows = parsedRows
}
}
inputs["templateKey"] = template.Key
inputs["maxRows"] = strconv.Itoa(maxRows)
}
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), InputRef: payload["inputRef"], Inputs: inputs})
if err != nil {
return bridgeExecutionError(base, err)
}
@@ -967,6 +1111,46 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
return base
}
func findBridgeQueryTemplate(plugin domain.GamePlugin, routeKey string, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, string) {
pageFound := false
pageAllowsTemplate := false
for _, page := range plugin.GameClientBridge.Pages {
if page.PageKey != routeKey {
continue
}
pageFound = true
if containsString(page.QueryTemplateKeys, templateKey) {
pageAllowsTemplate = true
}
}
if !pageFound || !pageAllowsTemplate {
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template is not declared by the bridge page"
}
var selected domain.GameClientBridgeQueryTemplateDeclaration
for _, template := range plugin.GameClientBridge.QueryTemplates {
if template.Key == templateKey {
selected = template
break
}
}
if selected.Key == "" {
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template is not declared by the plugin"
}
for _, page := range plugin.Pages {
if page.Key != routeKey {
continue
}
if !containsString(page.Permissions, selected.Permission) {
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template permission is not declared by the plugin page"
}
if !containsString(page.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) {
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template page does not declare remote access"
}
return selected, ""
}
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template plugin page is not declared"
}
func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
distribution, err := svc.GenerateRunDistributionForSession(sessionID, domain.RunDistributionGenerateRequest{
ServerInstanceID: request.ServerInstanceID,
@@ -1278,14 +1462,29 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
Pages: plugin.Pages,
Tags: plugin.Tags,
AIPurposes: plugin.AIPurposes,
ProductionLifecycle: plugin.ProductionLifecycle,
RemoteAccess: plugin.RemoteAccess,
RuntimeProfiles: plugin.RuntimeProfiles,
GameClientBridge: plugin.GameClientBridge,
ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status,
Source: "platform-registry",
}
}
func normalizedProductionLifecycle(lifecycle domain.GamePluginProductionLifecycle) domain.GamePluginProductionLifecycle {
if len(lifecycle.Operations) == 0 {
lifecycle.Operations = []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}
}
if lifecycle.DependencyPolicy == "" {
lifecycle.DependencyPolicy = "optional"
}
if len(lifecycle.ApprovalRequired) == 0 {
lifecycle.ApprovalRequired = []string{"disable", "rollback", "retire"}
}
return lifecycle
}
func marketplacePluginMatchesKeyword(plugin domain.PluginMarketplacePlugin, keyword string) bool {
keyword = strings.ToLower(strings.TrimSpace(keyword))
if keyword == "" {
+219 -3
View File
@@ -757,7 +757,7 @@ func TestCoreServiceManagesAIProviderMetadata(t *testing.T) {
if err != nil {
t.Fatalf("test enabled provider: %v", err)
}
if !testResult.Success || testResult.Mode != "metadata" {
if !testResult.Success || testResult.Mode != "provider" {
t.Fatalf("expected metadata test success, got %+v", testResult)
}
@@ -1022,6 +1022,147 @@ func TestCoreServiceRemoteAccessRequiresPluginDeclaration(t *testing.T) {
}
}
func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) {
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
RequestID: "query-template-dispatch-1",
PluginID: plugin.ID,
RouteKey: "remote",
ServerInstanceID: instance.ID,
Action: domain.PluginBridgeActionRemoteAccessRequest,
Payload: map[string]string{
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
"declarationKey": "scum-db-read",
"targetKey": "scum-db.player-lookup",
"idempotencyKey": "query-template-dispatch-1",
"input.templateKey": "players.by-id",
"input.playerId": "steam-123",
"input.maxRows": "100",
},
})
if err != nil {
t.Fatalf("execute declared sqlite query template: %v", err)
}
if queued.Status != "queued" || queued.Result["jobId"] == "" {
t.Fatalf("expected queued query template job, got %+v", queued)
}
job, err := svc.store.Jobs().Get(queued.Result["jobId"])
if err != nil {
t.Fatalf("get query template job: %v", err)
}
if job.ExecutionInput.TimeoutSeconds != 20 {
t.Fatalf("expected template timeout 20, got %+v", job.ExecutionInput)
}
if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" {
t.Fatalf("expected typed bounded query template inputs, got %#v", job.ExecutionInput.Inputs)
}
}
func TestCoreServiceDeniesUndeclaredOrMismatchedSQLiteQueryTemplateBeforeJob(t *testing.T) {
tests := []struct {
name string
templateKey string
declarationKey string
targetKey string
}{
{name: "undeclared template", templateKey: "players.unknown", declarationKey: "scum-db-read", targetKey: "scum-db.player-lookup"},
{name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "scum-db.player-lookup"},
{name: "mismatched target", templateKey: "players.by-id", declarationKey: "scum-db-read", targetKey: "scum-db.other"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
RequestID: "query-template-denied-1",
PluginID: plugin.ID,
RouteKey: "remote",
ServerInstanceID: instance.ID,
Action: domain.PluginBridgeActionRemoteAccessRequest,
Payload: map[string]string{
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
"declarationKey": test.declarationKey,
"targetKey": test.targetKey,
"idempotencyKey": "query-template-denied-1",
"input.templateKey": test.templateKey,
"input.playerId": "steam-123",
},
})
if err != nil {
t.Fatalf("execute denied sqlite query template: %v", err)
}
if result.Status != "denied" || result.Error == nil || result.Error.Code != "query_template_denied" {
t.Fatalf("expected query template denial, got %+v", result)
}
jobs, err := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
t.Fatalf("list jobs after denial: %v", err)
}
if len(jobs) != 0 {
t.Fatalf("query template denial created jobs: %+v", jobs)
}
})
}
}
func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing.T) {
_, plugin, _, _, _ := createSQLiteQueryBridgeFixture(t)
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.game-client.read"
for index := range plugin.Pages {
if plugin.Pages[index].Key == "remote" {
plugin.Pages[index].Permissions = []string{"server.remote.access"}
}
}
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "permission") {
t.Fatalf("expected query template permission denial, got %q", reason)
}
for index := range plugin.Pages {
if plugin.Pages[index].Key == "remote" {
plugin.Pages[index].Permissions = []string{"server.remote.access"}
plugin.Pages[index].BridgeActions = nil
}
}
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.remote.access"
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "remote access") {
t.Fatalf("expected query template remote access denial, got %q", reason)
}
}
func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) {
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
RequestID: "query-template-sql-rejected-1",
PluginID: plugin.ID,
RouteKey: "remote",
ServerInstanceID: instance.ID,
Action: domain.PluginBridgeActionRemoteAccessRequest,
Payload: map[string]string{
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
"declarationKey": "scum-db-read",
"targetKey": "scum-db.player-lookup",
"idempotencyKey": "query-template-sql-rejected-1",
"input.templateKey": "players.by-id",
"input.sqlText": "SELECT * FROM users",
},
})
if err != nil {
t.Fatalf("execute arbitrary SQL bridge input: %v", err)
}
if result.Status != "error" || result.Error == nil || !strings.Contains(strings.ToLower(result.Error.Message), "unsafe") {
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result)
}
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
if listErr != nil {
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr)
}
if len(jobs) != 0 {
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs)
}
}
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
svc := newTestCoreService()
registration := validPluginManifestRegistration()
@@ -1212,6 +1353,80 @@ func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlug
return plugin, endpoint
}
func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlugin, domain.RunEndpoint, string, domain.ServerInstance) {
t.Helper()
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
plugin.Permissions.RemoteAccess = true
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
Key: "remote",
Title: "Remote",
Path: "/remote",
Permissions: []string{"server.remote.access"},
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
})
plugin.RemoteAccess = domain.GamePluginRemoteAccess{
Methods: []string{"run"},
RunCapabilities: []string{capability},
DatabaseEngines: []string{"sqlite"},
}
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
Key: "scum-db-read",
Kind: "sqlite",
TargetKey: "scum-db.player-lookup",
Capabilities: []string{capability},
})
plugin.GameClientBridge = domain.GameClientBridgeManifest{
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
{
Key: "players.by-id",
Title: "Player lookup",
Permission: "server.remote.access",
Engine: "sqlite",
TransportKey: "scum-db-read",
TargetKey: "scum-db.player-lookup",
ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json",
ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json",
MaxRows: 25,
TimeoutSeconds: 20,
},
},
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100},
Pages: []domain.GameClientBridgePageContract{
{PageKey: "remote", QueryTemplateKeys: []string{"players.by-id"}},
},
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update sqlite query plugin fixture: %v", err)
}
endpoint.Capabilities = append(endpoint.Capabilities, capability)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update sqlite query endpoint fixture: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{
ID: "user-query-owner",
DisplayName: "Query Owner",
Email: "query-owner@example.test",
Roles: []string{"server-owner"},
PasswordHash: "secret-password",
})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
ID: "server-query-template",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "Query Template Server",
State: domain.ServerInstanceStateRunning,
})
if err != nil {
t.Fatalf("create sqlite query server fixture: %v", err)
}
return svc, plugin, endpoint, session, instance
}
func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domain.ServerInstance, profileKey string) domain.RuntimeBinding {
t.Helper()
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
@@ -1269,8 +1484,9 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
},
},
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
},
}
}