package service import ( "context" "crypto/pbkdf2" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "errors" "fmt" "strconv" "strings" "sync" "time" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) var ( ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden") ) const ( ServerDeletionForceConfirmation = "FORCE DELETE" runHeartbeatStaleAfter = 30 * time.Second ) 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) GetUser(string) (domain.User, error) ListUsers(domain.UserFilter) ([]domain.User, error) RegisterUser(domain.UserRegistration) (domain.AuthSession, error) LoginUser(domain.UserLogin) (domain.AuthSession, error) LogoutUser(string) error RotateUserSession(string) (domain.AuthSession, error) GetCurrentUser(string) (domain.User, error) UpdateCurrentUserProfile(string, domain.UserProfile) (domain.User, error) UpdateCurrentUserTheme(string, domain.UserThemePreference) (domain.UserThemePreference, error) CreateAIProvider(domain.AIProvider) (domain.AIProvider, error) UpdateAIProvider(string, domain.AIProvider) (domain.AIProvider, error) SetAIProviderStatus(string, domain.AIProviderStatus) (domain.AIProvider, error) TestAIProvider(string) (domain.AIProviderTestResult, error) ListAIProviderModels(string) (domain.AIProviderModels, error) GetAIProvider(string) (domain.AIProvider, error) ListAIProviders(domain.AIProviderFilter) ([]domain.AIProvider, error) InvokeAIForSession(string, domain.AIInvocationRequest) (domain.AIInvocationResponse, error) CreateGamePlugin(domain.GamePlugin) (domain.GamePlugin, error) RegisterGamePluginManifest(domain.GamePluginManifestRegistration) (domain.GamePlugin, error) GetGamePlugin(string) (domain.GamePlugin, error) ListGamePlugins(domain.GamePluginFilter) ([]domain.GamePlugin, error) ListMarketplacePlugins(domain.PluginMarketplaceFilter) ([]domain.PluginMarketplacePlugin, error) GetMarketplacePlugin(string) (domain.PluginMarketplacePlugin, error) SetMarketplacePluginState(string, domain.PluginMarketplaceStateAction) (domain.PluginMarketplacePlugin, error) AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) AuthorizePluginBridgeActionForSession(string, domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) ExecutePluginBridgeAction(string, domain.PluginBridgeExecuteRequest) (domain.PluginBridgeExecuteResponse, error) CreateRunEndpoint(domain.RunEndpoint) (domain.RunEndpoint, error) GetRunEndpoint(string) (domain.RunEndpoint, error) ListRunEndpoints(domain.RunEndpointFilter) ([]domain.RunEndpoint, error) RegisterRunHello(domain.RunControlHello) (domain.RunControlHelloResult, error) AcceptRunHeartbeat(domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) SubscribeRunControlEvents(domain.RunControlStreamRequest) (RunControlEventSubscription, error) AuthorizeRunRequestSignature(domain.RunRequestSignature) error CreateServerInstance(domain.ServerInstance) (domain.ServerInstance, error) CreateServerInstanceForSession(string, domain.ServerInstance) (domain.ServerInstance, error) CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) CreateServerInstanceWorkflowForSession(string, domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) GetServerDeploymentForSession(string, string) (domain.ServerDeploymentView, error) RevealServerDeploymentForSession(string, string) (domain.ServerDeploymentReveal, error) UpdateServerDeploymentForSession(string, string, domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error) DeployServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StartServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StopServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) QueryServerInstanceProcessForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) GetServerInstance(string) (domain.ServerInstance, error) GetServerInstanceForSession(string, string) (domain.ServerInstance, error) UpdateServerInstanceForSession(string, string, domain.ServerInstanceUpdate) (domain.ServerInstance, error) ListServerInstances(domain.ServerInstanceFilter) ([]domain.ServerInstance, error) ListServerInstancesForSession(string, domain.ServerInstanceFilter) ([]domain.ServerInstance, error) ListServerAdministratorCandidates(string, string) ([]domain.User, error) AddServerAdministrator(string, string, string) (domain.ServerInstance, error) RemoveServerAdministrator(string, string, string) (domain.ServerInstance, error) DeleteServerInstanceForSession(string, string, domain.ServerDeletionRequest) (domain.ServerInstance, error) GetPlatformResourceUsage() (domain.PlatformResourceUsage, error) ListServerMetricsForSession(string) ([]domain.ServerMetrics, 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) GetBackupForSession(string, string) (domain.BackupRecord, error) ListBackupsForSession(string, domain.BackupFilter) ([]domain.BackupRecord, error) ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error) RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error) GetServerConfigForSession(string, string) (domain.ServerConfig, error) GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error) GetServerFileWorkspaceForSession(string, string) (domain.ServerFileWorkspaceView, error) ListServerFilesForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error) RefreshServerFileListForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error) BrowseServerFilesForSession(context.Context, string, domain.ServerFileListRequest) (domain.ServerFileListResult, error) ReadServerFileForSession(string, domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) WriteServerFileForSession(string, domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) UploadServerFileForSession(string, domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) PrepareServerFileDownloadForSession(string, domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error) CreateJob(domain.Job) (domain.Job, error) GetJob(string) (domain.Job, error) ListJobs(domain.JobFilter) ([]domain.Job, error) GetJobForSession(string, string) (domain.Job, error) ListJobsForSession(string, domain.JobFilter) ([]domain.Job, error) RequestRunJobCancelForSession(string, domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) ClaimRunJob(domain.RunJobClaim) (domain.RunJobClaimResult, error) ClaimRunJobWithWait(context.Context, domain.RunJobClaim) (domain.RunJobClaimResult, error) AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error) UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error) CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error) ReportRunLifecycle(domain.RunLifecycleReport) (domain.RunLifecycleReportResult, error) GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error) GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error) GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error) GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) ReadRunFileInputChunk(domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) ReconcileRunJobs(domain.RunJobReconcile) (domain.RunJobReconcileResult, error) CreateArtifact(domain.Artifact) (domain.Artifact, error) GetArtifact(string) (domain.Artifact, error) ListArtifacts(domain.ArtifactFilter) ([]domain.Artifact, error) ListArtifactsForSession(string, domain.ArtifactFilter) ([]domain.Artifact, error) GetArtifactForSession(string, string) (domain.Artifact, error) OpenArtifactDownloadForSession(string, domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error) ReadArtifactContentForSession(string, domain.ArtifactContentRequest) (domain.ArtifactContent, error) OpenArtifactContentStreamForSession(string, domain.ArtifactContentRequest) (ArtifactContentStream, error) GetServerRuntimeActionsForSession(string, string) (domain.ServerRuntimeActions, error) GetServerRuntimeBindingForSession(string, string) (domain.RuntimeBindingView, error) UpdateServerRuntimeBindingForSession(string, string, domain.RuntimeBindingUpdate) (domain.RuntimeBindingView, error) GenerateRunDistributionForSession(string, domain.RunDistributionGenerateRequest) (domain.RunDistribution, error) OpenLatestRunDistributionDownloadForSession(string, string) (domain.ArtifactDownloadReference, error) ResetComponentKeyForSession(string, domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error) AuthenticateComponent(domain.ComponentAuthenticationRequest) (domain.ComponentAuthenticationResult, error) QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, 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) ListSCUMUsersForSession(string, domain.SCUMUserFilter) ([]domain.SCUMUser, error) ListSCUMUserTrajectoriesForSession(string, domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) ListSCUMVehiclesForSession(string, domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) ListSCUMVehicleTrajectoriesForSession(string, domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) ListSCUMVehicleLocksForSession(string, domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) GetSCUMSurfaceForSession(string, string) (domain.SCUMSurface, error) IngestSCUMFacts(domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error) ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error) PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error) DeletePluginDataForSession(string, string, string, string, string) error ApplyPluginDataTransactionForSession(string, domain.PluginDataTransaction) ([]domain.PluginDataRecord, error) PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error) ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error) GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error) QueueDependencyJobForSession(string, domain.DependencyJobRequest) (domain.Job, error) QueueLogBackfillForSession(string, domain.LogBackfillRequest) (domain.Job, error) OpenArtifactTransfer(domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error) UploadArtifactChunk(domain.ArtifactChunkUpload) (domain.ArtifactChunkUploadResult, error) QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery) (domain.ArtifactTransferStatusResult, error) CompleteArtifactTransfer(domain.ArtifactTransferComplete) (domain.ArtifactTransferCompleteResult, error) CreateLogStream(domain.LogStream) (domain.LogStream, error) GetLogStream(string) (domain.LogStream, error) ListLogStreams(domain.LogStreamFilter) ([]domain.LogStream, error) GetLogStreamForSession(string, string) (domain.LogStream, error) ListLogStreamsForSession(string, domain.LogStreamFilter) ([]domain.LogStream, error) QueryLogStreamForSession(string, domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) SubscribeLogEvents(string) (LogEventSubscription, error) SubscribeLogEventsForSession(string, string) (LogEventSubscription, error) IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error) GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error) QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) SeedPlatformAdmin(string, string) error } type CoreService struct { store repo.Store now func() time.Time authMu sync.Mutex authSessions map[string]string controlMu sync.Mutex runSessions map[string]domain.RunControlSession runSessionSeq uint64 controlStreamMu sync.Mutex controlStreamSeq map[string]uint64 controlStreamEvents map[string]domain.RunControlEvent controlStreamWaiters map[string][]chan domain.RunControlEvent jobMu sync.Mutex jobWaitMu sync.Mutex jobWaiters map[string][]chan struct{} bridgeMu sync.Mutex bridgeSeq uint64 logStore LogBodyStore logIngestMu [64]sync.Mutex logEventMu sync.Mutex logEventSubscribers map[uint64]logEventSubscriber logEventSubscriberSeq uint64 artifactStore ArtifactBodyStore artifactMu sync.Mutex artifactTransfers map[string]domain.ArtifactTransferSession artifactPayloads map[string][]byte artifactTransferSeq uint64 pluginOperationsMu sync.Mutex sourceRCONCommands *sourceRCONCommandBroker aiProviderClient AIProviderClient secretEnvelope SecretEnvelope networkFingerprintKey []byte distributionBuilder DistributionBuilder distributionBuildMu sync.Mutex distributionBuilds map[string]struct{} } var _ Core = (*CoreService)(nil) func NewCoreService(store repo.Store) *CoreService { return newCoreService(store, func() time.Time { return time.Now().UTC() }) } func NewCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore) *CoreService { return newCoreServiceWithLogStore(store, logStore, func() time.Time { return time.Now().UTC() }) } func newCoreService(store repo.Store, now func() time.Time) *CoreService { return newCoreServiceWithLogStore(store, NewMemoryLogBodyStore(), now) } func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now func() time.Time) *CoreService { if logStore == nil { logStore = NewMemoryLogBodyStore() } artifactStore := NewMemoryArtifactBodyStore() service := &CoreService{ store: store, now: now, authSessions: map[string]string{}, runSessions: map[string]domain.RunControlSession{}, controlStreamSeq: map[string]uint64{}, controlStreamEvents: map[string]domain.RunControlEvent{}, controlStreamWaiters: map[string][]chan domain.RunControlEvent{}, jobWaiters: map[string][]chan struct{}{}, logStore: logStore, logEventSubscribers: map[uint64]logEventSubscriber{}, artifactStore: artifactStore, artifactTransfers: map[string]domain.ArtifactTransferSession{}, artifactPayloads: map[string][]byte{}, sourceRCONCommands: newSourceRCONCommandBroker(now), aiProviderClient: MockAIProviderClient{}, secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey), networkFingerprintKey: []byte(developmentSecretEnvelopeKey), distributionBuilder: unconfiguredDistributionBuilder{}, distributionBuilds: map[string]struct{}{}, } return service } // ConfigureDistributionBuilder installs the platform-owned builder that // executes distribution builds. Build execution is a platform responsibility, // so a nil builder leaves the platform reporting an unconfigured builder rather // than falling back to a machine-side run endpoint. func (svc *CoreService) ConfigureDistributionBuilder(builder DistributionBuilder) { if builder == nil { builder = unconfiguredDistributionBuilder{} } svc.distributionBuildMu.Lock() svc.distributionBuilder = builder svc.distributionBuildMu.Unlock() jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: platformDistributionBuilderEndpointID}) if err != nil { return } for _, job := range jobs { if job.Capability == domain.JobCapabilityDistributionBuild && !isTerminalJobState(job.State) { svc.enqueueDistributionBuild(job) } } } func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, artifactStore ArtifactBodyStore) (*CoreService, error) { if artifactStore == nil { artifactStore = NewMemoryArtifactBodyStore() } service := newCoreServiceWithLogStore(store, logStore, func() time.Time { return time.Now().UTC() }) service.artifactStore = artifactStore sessions, err := artifactStore.LoadTransfers() if err != nil { return nil, err } for _, session := range sessions { service.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) } if err := service.recoverJobLogStreams(); err != nil { return nil, err } if err := service.recoverLogCursors(); err != nil { return nil, err } if err := service.RecoverIncompleteBackups(); err != nil { return nil, err } if err := service.ReconcileGameClientBridgeCommands(); err != nil { return nil, err } return service, nil } func (svc *CoreService) recoverJobLogStreams() error { jobs, err := svc.store.Jobs().List(domain.JobFilter{}) if err != nil { return err } stamp := svc.now() for _, job := range jobs { if strings.TrimSpace(job.ID) == "" || strings.TrimSpace(job.ServerInstanceID) == "" { continue } instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) if errors.Is(err, repo.ErrNotFound) { continue } if err != nil { return err } if instance.State == domain.ServerInstanceStateDeleted { continue } if err := svc.ensureJobLogStreams(job, stamp); err != nil { return err } } return nil } func (svc *CoreService) recoverLogCursors() error { store, ok := svc.logStore.(interface{ LatestSeq(string) (uint64, error) }) if !ok { return nil } streams, err := svc.store.LogStreams().List(domain.LogStreamFilter{}) if err != nil { return err } for _, stream := range streams { latest, latestErr := store.LatestSeq(stream.ID) if latestErr != nil || latest <= stream.LatestSeq { if latestErr != nil { return latestErr } continue } stream.LatestSeq = latest stream.UpdatedAt = svc.now() if err := svc.store.LogStreams().Update(stream); err != nil { return err } } return nil } func (svc *CoreService) CreateUser(user domain.User) (domain.User, error) { if strings.TrimSpace(user.ID) == "" { id, err := svc.nextUserID(user) if err != nil { return domain.User{}, err } user.ID = id } if user.Status == "" { user.Status = domain.UserStatusActive } if len(user.Roles) == 0 { user.Roles = []string{"server-admin"} } if strings.TrimSpace(user.PasswordHash) != "" && !strings.Contains(user.PasswordHash, "$") { hash, err := hashPassword(user.PasswordHash) if err != nil { return domain.User{}, err } user.PasswordHash = hash } stamp := svc.now() if user.CreatedAt.IsZero() { user.CreatedAt = stamp } if user.UpdatedAt.IsZero() { user.UpdatedAt = stamp } if err := validator.ValidateUser(user); err != nil { return domain.User{}, err } if err := svc.store.Users().Create(user); err != nil { return domain.User{}, err } return domain.CopyUser(user), nil } func (svc *CoreService) UpdateUser(id string, user domain.User) (domain.User, error) { existing, err := svc.store.Users().Get(id) if err != nil { return domain.User{}, err } user.ID = id if user.PasswordHash == "" { user.PasswordHash = existing.PasswordHash } else if !strings.Contains(user.PasswordHash, "$") { hash, err := hashPassword(user.PasswordHash) if err != nil { return domain.User{}, err } user.PasswordHash = hash } if user.CreatedAt.IsZero() { user.CreatedAt = existing.CreatedAt } user.UpdatedAt = svc.now() if user.Status == "" { user.Status = existing.Status } if user.Roles == nil { user.Roles = domain.CopyStringSlice(existing.Roles) } if err := validator.ValidateUser(user); err != nil { return domain.User{}, err } if err := svc.store.Users().Update(user); err != nil { return domain.User{}, err } if user.Status != domain.UserStatusActive { if err := svc.revokeUserSessions(user.ID); err != nil { return domain.User{}, err } } return domain.CopyUser(user), nil } func (svc *CoreService) GetUser(id string) (domain.User, error) { return svc.store.Users().Get(id) } func (svc *CoreService) ListUsers(filter domain.UserFilter) ([]domain.User, error) { return svc.store.Users().List(filter) } func (svc *CoreService) RegisterUser(registration domain.UserRegistration) (domain.AuthSession, error) { if len([]rune(registration.Password)) < 6 { return domain.AuthSession{}, validationError("password must be at least 6 characters") } hash, err := hashPassword(registration.Password) if err != nil { return domain.AuthSession{}, err } users, err := svc.store.Users().List(domain.UserFilter{}) if err != nil { return domain.AuthSession{}, err } firstUser := len(users) == 0 user := domain.User{ ID: userIDFromEmail(registration.Email), DisplayName: registration.DisplayName, Email: registration.Email, Status: domain.UserStatusPending, Roles: []string{"server-admin"}, PasswordHash: hash, Profile: registration.Profile, } if firstUser { user.Status = domain.UserStatusActive user.Roles = []string{"platform-admin"} } created, err := svc.CreateUser(user) if err != nil { return domain.AuthSession{}, err } if firstUser { return svc.issueAuthSession(created, "首个账号已创建为平台管理员。") } return domain.AuthSession{ User: created, Status: "pending", Message: "注册申请已提交,等待平台管理员审核。", }, nil } func (svc *CoreService) LoginUser(login domain.UserLogin) (domain.AuthSession, error) { var matched domain.User users, err := svc.store.Users().List(domain.UserFilter{}) if err != nil { return domain.AuthSession{}, err } account := strings.ToLower(strings.TrimSpace(login.Account)) for _, user := range users { if strings.ToLower(user.ID) == account || strings.ToLower(strings.TrimSpace(user.Email)) == account { matched = user break } } if matched.ID == "" || !verifyPassword(matched.PasswordHash, login.Password) { return domain.AuthSession{}, ErrUnauthorized } if matched.Status == domain.UserStatusPending { return domain.AuthSession{}, ErrForbidden } if matched.Status == domain.UserStatusDisabled { return domain.AuthSession{}, ErrForbidden } return svc.issueAuthSession(matched, "登录成功") } func (svc *CoreService) LogoutUser(sessionID string) error { return svc.revokeAuthSession(sessionID) } func (svc *CoreService) GetCurrentUser(sessionID string) (domain.User, error) { userID, err := svc.userIDForSession(sessionID) if err != nil { return domain.User{}, err } return svc.store.Users().Get(userID) } func (svc *CoreService) UpdateCurrentUserProfile(sessionID string, profile domain.UserProfile) (domain.User, error) { user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.User{}, err } user.Profile = profile return svc.UpdateUser(user.ID, user) } func (svc *CoreService) UpdateCurrentUserTheme(sessionID string, preference domain.UserThemePreference) (domain.UserThemePreference, error) { user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.UserThemePreference{}, err } preference.UserID = user.ID preference.Persistence = "api" preference.UpdatedAt = svc.now() user.Theme = preference if _, err := svc.UpdateUser(user.ID, user); err != nil { return domain.UserThemePreference{}, err } return preference, nil } func (svc *CoreService) SeedLocalPlatformAdmin() error { return svc.SeedPlatformAdmin("operator.local@example.test", "operator-local") } func (svc *CoreService) SeedPlatformAdmin(email string, password string) error { email = strings.TrimSpace(email) if email == "" { email = "operator.local@example.test" } if len([]rune(password)) < 12 { return validationError("bootstrap admin password must be at least 12 characters") } _, err := svc.store.Users().Get("user-admin") if err == nil { return nil } if !errors.Is(err, repo.ErrNotFound) { return err } return svc.store.Users().Create(domain.User{ ID: "user-admin", DisplayName: "Operator", Email: email, Status: domain.UserStatusActive, Roles: []string{"platform-admin"}, PasswordHash: mustHashPassword(password), Profile: domain.UserProfile{ContactNote: "bootstrap platform admin"}, CreatedAt: svc.now(), UpdatedAt: svc.now(), }) } func (svc *CoreService) CreateAIProvider(provider domain.AIProvider) (domain.AIProvider, error) { if provider.Status == "" { provider.Status = domain.AIProviderStatusActive } if err := validator.ValidateAIProvider(provider); err != nil { return domain.AIProvider{}, err } if err := svc.store.AIProviders().Create(provider); err != nil { return domain.AIProvider{}, err } return domain.CopyAIProvider(provider), nil } func (svc *CoreService) UpdateAIProvider(id string, provider domain.AIProvider) (domain.AIProvider, error) { existing, err := svc.store.AIProviders().Get(id) if err != nil { return domain.AIProvider{}, err } provider.ID = id provider.Status = existing.Status if err := validator.ValidateAIProvider(provider); err != nil { return domain.AIProvider{}, err } if err := svc.store.AIProviders().Update(provider); err != nil { return domain.AIProvider{}, err } return domain.CopyAIProvider(provider), nil } func (svc *CoreService) SetAIProviderStatus(id string, status domain.AIProviderStatus) (domain.AIProvider, error) { if status != domain.AIProviderStatusActive && status != domain.AIProviderStatusDisabled { return domain.AIProvider{}, validationError("status must be active or disabled") } provider, err := svc.store.AIProviders().Get(id) if err != nil { return domain.AIProvider{}, err } provider.Status = status if err := validator.ValidateAIProvider(provider); err != nil { return domain.AIProvider{}, err } if err := svc.store.AIProviders().Update(provider); err != nil { return domain.AIProvider{}, err } return domain.CopyAIProvider(provider), nil } func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult, error) { provider, err := svc.store.AIProviders().Get(id) if err != nil { return domain.AIProviderTestResult{}, err } result := domain.AIProviderTestResult{ ProviderID: provider.ID, Mode: "provider", Success: true, Message: "provider invocation passed", } if err := validator.ValidateAIProvider(provider); err != nil { result.Success = false result.Message = "provider validation failed" var validationErr validator.ValidationError if errors.As(err, &validationErr) { result.Violations = append(result.Violations, validationErr.Violations...) } else { result.Violations = append(result.Violations, err.Error()) } } if provider.Status != domain.AIProviderStatusActive { result.Success = false 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"} } return domain.CopyAIProviderTestResult(result), nil } func (svc *CoreService) ListAIProviderModels(id string) (domain.AIProviderModels, error) { provider, err := svc.store.AIProviders().Get(id) if err != nil { return domain.AIProviderModels{}, err } return domain.CopyAIProviderModels(domain.AIProviderModels{ ProviderID: provider.ID, DefaultModel: provider.DefaultModel, Models: provider.Models, }), nil } func (svc *CoreService) GetAIProvider(id string) (domain.AIProvider, error) { return svc.store.AIProviders().Get(id) } func (svc *CoreService) ListAIProviders(filter domain.AIProviderFilter) ([]domain.AIProvider, error) { return svc.store.AIProviders().List(filter) } func (svc *CoreService) CreateGamePlugin(plugin domain.GamePlugin) (domain.GamePlugin, error) { if plugin.Status == "" { plugin.Status = domain.GamePluginStatusInstalled } plugin.ProductionLifecycle = normalizedProductionLifecycle(plugin.ProductionLifecycle) if err := validator.ValidateGamePlugin(plugin); err != nil { return domain.GamePlugin{}, err } return svc.upsertLatestGamePlugin(plugin) } func (svc *CoreService) RegisterGamePluginManifest(registration domain.GamePluginManifestRegistration) (domain.GamePlugin, error) { if err := validator.ValidateGamePluginManifestRegistration(registration); err != nil { return domain.GamePlugin{}, err } plugin := gamePluginFromManifestRegistration(registration) plugin.ProductionLifecycle = normalizedProductionLifecycle(plugin.ProductionLifecycle) if err := validator.ValidateGamePlugin(plugin); err != nil { return domain.GamePlugin{}, err } return svc.upsertLatestGamePlugin(plugin) } func (svc *CoreService) upsertLatestGamePlugin(plugin domain.GamePlugin) (domain.GamePlugin, error) { plugins, err := svc.store.GamePlugins().List(domain.GamePluginFilter{}) if err != nil { return domain.GamePlugin{}, err } identity := domain.GamePluginIdentityKey(plugin) var current domain.GamePlugin matched := make([]domain.GamePlugin, 0, len(plugins)) for _, candidate := range plugins { if domain.GamePluginIdentityKey(candidate) != identity { continue } matched = append(matched, candidate) if current.ID == "" || domain.GamePluginIsNewer(candidate, current) { current = candidate } } if current.ID != "" && !domain.GamePluginIsNewer(plugin, current) { if err := svc.pruneOlderGamePluginVersions(current, matched); err != nil { return domain.GamePlugin{}, err } return domain.CopyGamePlugin(current), nil } if _, err := svc.store.GamePlugins().Get(plugin.ID); err == nil { if err := svc.store.GamePlugins().Update(plugin); err != nil { return domain.GamePlugin{}, err } } else if errors.Is(err, repo.ErrNotFound) { if err := svc.store.GamePlugins().Create(plugin); err != nil { return domain.GamePlugin{}, err } } else { return domain.GamePlugin{}, err } for _, stale := range matched { if stale.ID == plugin.ID { continue } if err := svc.replaceGamePluginReference(stale.ID, plugin.ID, plugin.Version); err != nil { return domain.GamePlugin{}, err } if err := svc.store.GamePlugins().Delete(stale.ID); err != nil && !errors.Is(err, repo.ErrNotFound) { return domain.GamePlugin{}, err } } if err := svc.refreshServerPluginReferences(plugin.ID, plugin.Version); err != nil { return domain.GamePlugin{}, err } return domain.CopyGamePlugin(plugin), nil } func (svc *CoreService) pruneOlderGamePluginVersions(latest domain.GamePlugin, plugins []domain.GamePlugin) error { for _, plugin := range plugins { if plugin.ID == latest.ID { continue } if err := svc.replaceGamePluginReference(plugin.ID, latest.ID, latest.Version); err != nil { return err } if err := svc.store.GamePlugins().Delete(plugin.ID); err != nil && !errors.Is(err, repo.ErrNotFound) { return err } } return svc.refreshServerPluginReferences(latest.ID, latest.Version) } func (svc *CoreService) replaceGamePluginReference(fromPluginID, toPluginID, toPluginVersion string) error { if strings.TrimSpace(fromPluginID) == "" || fromPluginID == toPluginID { return nil } stamp := svc.now() instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{PluginID: fromPluginID}) if err != nil { return err } for _, instance := range instances { instance.PluginID = toPluginID instance.PluginVersion = toPluginVersion instance.UpdatedAt = stamp if err := validator.ValidateStoredServerInstance(instance); err != nil { return err } if err := svc.store.ServerInstances().Update(instance); err != nil { return err } } bindings, err := svc.store.RuntimeBindings().List(domain.RuntimeBindingFilter{}) if err != nil { return err } for _, binding := range bindings { if binding.PluginID != fromPluginID { continue } binding.PluginID = toPluginID binding.PluginVersion = toPluginVersion binding.UpdatedAt = stamp if err := svc.store.RuntimeBindings().Update(binding); err != nil { return err } } installations, err := svc.store.PluginLifecycles().List(domain.PluginLifecycleFilter{PluginID: fromPluginID}) if err != nil { return err } for _, installation := range installations { installation.PluginID = toPluginID if installation.TargetVersion != "" { installation.TargetVersion = toPluginVersion } installation.UpdatedAt = stamp if err := svc.store.PluginLifecycles().Update(installation); err != nil { return err } } previews, err := svc.store.AIConfigDiffs().List(domain.AIConfigDiffFilter{PluginID: fromPluginID}) if err != nil { return err } for _, preview := range previews { preview.PluginID = toPluginID preview.UpdatedAt = stamp if err := svc.store.AIConfigDiffs().Update(preview); err != nil { return err } } return svc.replacePluginDataReferences(fromPluginID, toPluginID) } func (svc *CoreService) replacePluginDataReferences(fromPluginID, toPluginID string) error { items, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: fromPluginID}) if err != nil { return err } upserts := make([]domain.PluginDataRecord, 0, len(items)) deleteIDs := make([]string, 0, len(items)) for _, item := range items { deleteIDs = append(deleteIDs, item.ID) if legacySCUMPluginDataCollection(fromPluginID, item.Collection) { continue } item.PluginID = toPluginID item.ID = pluginDataID(item.ServerInstanceID, toPluginID, item.Collection, item.Key) if existing, getErr := svc.store.PluginDataRecords().Get(item.ID); getErr == nil && existing.UpdatedAt.After(item.UpdatedAt) { continue } else if getErr != nil && !errors.Is(getErr, repo.ErrNotFound) { return getErr } upserts = append(upserts, item) } if len(upserts) == 0 && len(deleteIDs) == 0 { return nil } return svc.store.PluginDataRecords().Apply(upserts, deleteIDs) } // refreshServerPluginReferences keeps existing server projections usable when a // manifest is refreshed in place. The server and its logical runtime binding // carry the manifest version used for lifecycle validation; leaving either at a // stale version would make a healthy existing server impossible to start after // the registry refresh. func (svc *CoreService) refreshServerPluginReferences(pluginID, pluginVersion string) error { if strings.TrimSpace(pluginID) == "" || strings.TrimSpace(pluginVersion) == "" { return nil } stamp := svc.now() instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{PluginID: pluginID}) if err != nil { return err } for _, instance := range instances { if instance.PluginVersion != pluginVersion { instance.PluginVersion = pluginVersion instance.UpdatedAt = stamp if err := validator.ValidateStoredServerInstance(instance); err != nil { return err } if err := svc.store.ServerInstances().Update(instance); err != nil { return err } } bindings, err := svc.store.RuntimeBindings().List(domain.RuntimeBindingFilter{ServerInstanceID: instance.ID}) if err != nil { return err } for _, binding := range bindings { if binding.PluginID != pluginID || binding.PluginVersion == pluginVersion { continue } binding.PluginVersion = pluginVersion binding.UpdatedAt = stamp if err := svc.store.RuntimeBindings().Update(binding); err != nil { return err } } } return nil } func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRegistration) domain.GamePlugin { registration = domain.CopyGamePluginManifestRegistration(registration) manifest := registration.Manifest return domain.GamePlugin{ ID: manifest.ID, Name: manifest.Name, Description: manifest.Description, Version: manifest.Version, ServerType: manifest.Server.Type, ServerDisplayName: manifest.Server.DisplayName, SupportedOS: manifest.Server.SupportedOS, ManifestRef: registration.ManifestRef, CreateFormSchemaRef: manifest.Server.CreateFormSchema, CreateFields: manifest.Server.CreateFields, RequiredRunCapabilities: manifest.Capabilities, DeclaredPermissions: manifest.Permissions, Permissions: pluginPermissionsFromManifest(manifest.Permissions), LifecycleActions: manifest.Actions, LifecycleAssets: lifecycleAssetsFromManifestRegistration(registration), BridgeActions: manifest.Bridge.Actions, Pages: manifest.Pages, FileWorkspace: manifest.FileWorkspace, Tags: manifest.Tags, AIPurposes: manifest.AI.Purposes, ProductionLifecycle: manifest.ProductionLifecycle, RemoteAccess: manifest.RemoteAccess, RuntimeProfiles: manifest.RuntimeProfiles, GameClientBridge: manifest.GameClientBridge, Status: domain.GamePluginStatusInstalled, } } func lifecycleAssetsFromManifestRegistration(registration domain.GamePluginManifestRegistration) []domain.PluginAssetFile { assets := domain.CopyPluginAssetFiles(registration.AssetFiles) if len(registration.Manifest.AssetFiles) == 0 { return assets } modeByPath := map[string]int{} for _, file := range registration.Manifest.AssetFiles { if file.Mode != 0 { modeByPath[file.Path] = file.Mode } } for i := range assets { if assets[i].Mode == 0 { assets[i].Mode = modeByPath[assets[i].Path] } } return assets } func (svc *CoreService) AuthorizePluginBridgeAction(request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) { if err := validator.ValidatePluginBridgeAuthorizeRequest(request); err != nil { return domain.PluginBridgeAuthorization{}, err } plugin, err := svc.store.GamePlugins().Get(request.PluginID) if err != nil { return domain.PluginBridgeAuthorization{}, err } result, err := validator.AuthorizePluginBridgeAction(plugin, request) if err != nil { return domain.PluginBridgeAuthorization{}, err } return domain.CopyPluginBridgeAuthorization(result), nil } func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request domain.PluginBridgeExecuteRequest) (domain.PluginBridgeExecuteResponse, error) { request = domain.CopyPluginBridgeExecuteRequest(request) if err := validator.ValidatePluginBridgeExecuteRequest(request); err != nil { return domain.PluginBridgeExecuteResponse{}, err } base := domain.PluginBridgeExecuteResponse{ RequestID: request.RequestID, PluginID: request.PluginID, RouteKey: request.RouteKey, ServerInstanceID: request.ServerInstanceID, Action: request.Action, } plugin, err := svc.store.GamePlugins().Get(request.PluginID) if err != nil { return domain.PluginBridgeExecuteResponse{}, err } authorization, err := validator.AuthorizePluginBridgeAction(plugin, domain.PluginBridgeAuthorizeRequest{ PluginID: request.PluginID, RouteKey: request.RouteKey, ServerInstanceID: request.ServerInstanceID, Action: request.Action, AIPurpose: request.AIPurpose, }) if err != nil { return domain.PluginBridgeExecuteResponse{}, err } if !authorization.Allowed { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "permission_denied", Message: safeBridgeReason(authorization.Reason)} return domain.CopyPluginBridgeExecuteResponse(base), nil } var instance domain.ServerInstance if request.ServerInstanceID != "" { instance, err = svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) if err != nil { return domain.PluginBridgeExecuteResponse{}, err } if instance.PluginID != request.PluginID { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "server_scope_denied", Message: "server instance is outside plugin scope"} return domain.CopyPluginBridgeExecuteResponse(base), nil } } switch request.Action { case domain.PluginBridgeActionServerInstancesRead: base.Status = "ok" base.Result = map[string]string{ "serverInstanceId": instance.ID, "pluginId": instance.PluginID, "pluginVersion": instance.PluginVersion, "runEndpointId": instance.RunEndpointID, "state": string(instance.State), "configVersion": strconv.Itoa(instance.ConfigVersion), } case domain.PluginBridgeActionJobsDispatch: base = svc.executeBridgeJobDispatch(sessionID, base, plugin, instance, request.Payload) case domain.PluginBridgeActionLogsQuery: base = svc.executeBridgeLogsQuery(base, instance, request.Payload) case domain.PluginBridgeActionFilesRequest: base = svc.executeBridgeFileRequest(sessionID, base, request) case domain.PluginBridgeActionRemoteAccessRequest: base = svc.executeBridgeRemoteAccessRequest(sessionID, base, plugin, instance, request.Payload) case domain.PluginBridgeActionRunDistribution: base = svc.executeBridgeRunDistribution(sessionID, base, request) case domain.PluginBridgeActionDependenciesRequest: base = svc.executeBridgeDependenciesRequest(sessionID, base, plugin, instance, request.Payload) case domain.PluginBridgeActionLogsBackfillRequest: base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload) case domain.PluginBridgeActionPluginLifecycle: base = svc.executeBridgePluginLifecycle(sessionID, base, request) case domain.PluginBridgeActionArtifactsOpen: base = svc.executeBridgeArtifactOpen(sessionID, base, request) case domain.PluginBridgeActionAIInvoke: base = svc.executeBridgeAIInvoke(sessionID, base, request) default: base.Status = "unsupported" base.Error = &domain.PluginBridgeSafeError{Code: "unsupported_action", Message: "bridge action is not supported"} } return domain.CopyPluginBridgeExecuteResponse(base), nil } func (svc *CoreService) executeBridgeArtifactOpen(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { artifactID := strings.TrimSpace(request.Payload["artifactId"]) if artifactID == "" { base.Status = "error" base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "artifactId is required"} return base } reference, err := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: artifactID}) if err != nil { return bridgeExecutionError(base, err) } if reference.OwnerKind == domain.ArtifactOwnerKindJob { job, err := svc.GetJob(reference.OwnerID) if err != nil { return bridgeExecutionError(base, err) } if job.ServerInstanceID != request.ServerInstanceID { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "server_scope_denied", Message: "artifact is outside server scope"} return base } } if reference.OwnerKind == domain.ArtifactOwnerKindServerInstance && reference.OwnerID != request.ServerInstanceID { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "server_scope_denied", Message: "artifact is outside server scope"} return base } base.Status = "ok" base.Result = map[string]string{ "artifactId": reference.ArtifactID, "filename": reference.Filename, "contentType": reference.ContentType, "sizeBytes": strconv.FormatInt(reference.SizeBytes, 10), "checksum": reference.Checksum, "downloadUrl": reference.DownloadURL, "expiresAt": reference.ExpiresAt.Format(time.RFC3339), "rangeSupported": strconv.FormatBool(reference.RangeSupported), "chunkSizeBytes": strconv.Itoa(reference.ChunkSizeBytes), "storageBehavior": reference.StorageBehavior, } return base } func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { response, err := svc.InvokeAIForSession(sessionID, domain.AIInvocationRequest{ RequestID: request.RequestID, PluginID: request.PluginID, RouteKey: request.RouteKey, ServerInstanceID: request.ServerInstanceID, Purpose: request.AIPurpose, Model: request.Payload["model"], Prompt: defaultBridgeValue(request.Payload["prompt"], "Review the current server context and provide a safe recommendation."), CurrentConfig: request.Payload["currentConfig"], ContextRefs: map[string]string{"server": "server://" + request.ServerInstanceID}, }) if err != nil { return bridgeExecutionError(base, err) } base.Status = response.Status base.Result = map[string]string{ "purpose": response.Purpose, "recommendation": response.Recommendation, "providerId": response.ProviderID, "model": response.Model, "mocked": strconv.FormatBool(response.Usage.Mocked), } 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} } return base } func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { 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)}) 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} 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 == "" { capability = "process.start" } if !containsString(plugin.RequiredRunCapabilities, capability) { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "requested job capability is not declared by plugin"} return base } lifecycleAction := domain.ServerLifecycleAction(strings.TrimSpace(payload["lifecycleAction"])) if lifecycleAction == domain.ServerLifecycleActionStart || lifecycleAction == domain.ServerLifecycleActionStop { expectedVersion, _ := strconv.Atoi(payload["expectedConfigVersion"]) command := domain.ServerLifecycleCommand{ ServerInstanceID: instance.ID, ExpectedConfigVersion: expectedVersion, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), } var result domain.ServerLifecycleResult var err error switch lifecycleAction { case domain.ServerLifecycleActionStart: if capability != domain.LifecycleCapabilityStart { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "lifecycle action must match requested capability"} return base } result, err = svc.StartServerInstanceForSession(sessionID, command) case domain.ServerLifecycleActionStop: if capability != domain.LifecycleCapabilityStop { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "lifecycle action must match requested capability"} return base } result, err = svc.StopServerInstanceForSession(sessionID, command) } if err != nil { return bridgeExecutionError(base, err) } base.Status = "queued" base.Result = map[string]string{ "jobId": result.Job.ID, "state": string(result.Job.State), "capability": result.Job.Capability, "lifecycleAction": string(result.Action), "serverInstanceId": result.Instance.ID, } return base } job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-bridge", base.RequestID, capability), ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: capability, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), Progress: domain.JobProgress{Percent: 0, Message: "plugin bridge job queued"}, }) if err != nil { return bridgeExecutionError(base, err) } base.Status = "queued" base.Result = map[string]string{"jobId": job.ID, "state": string(job.State), "capability": job.Capability} return base } func (svc *CoreService) executeBridgeLogsQuery(base domain.PluginBridgeExecuteResponse, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { streamID := strings.TrimSpace(payload["logStreamId"]) if streamID == "" { base.Status = "error" base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "logStreamId is required"} return base } stream, err := svc.GetLogStream(streamID) if err != nil { return bridgeExecutionError(base, err) } if stream.ServerInstanceID != instance.ID { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "server_scope_denied", Message: "log stream is outside server scope"} return base } afterSeq, _ := strconv.ParseUint(payload["afterSeq"], 10, 64) limit, _ := strconv.Atoi(payload["limit"]) result, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: streamID, AfterSeq: afterSeq, Limit: limit}) if err != nil { return bridgeExecutionError(base, err) } base.Status = "ok" base.Result = map[string]string{ "logStreamId": stream.ID, "entryCount": strconv.Itoa(len(result.Entries)), "nextSeq": strconv.FormatUint(result.NextSeq, 10), "latestSeq": strconv.FormatUint(result.LatestSeq, 10), } return base } func (svc *CoreService) executeBridgeFileRequest(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { payload := request.Payload operation := domain.FileOperationKind(defaultBridgeValue(payload["operation"], string(domain.FileOperationRead))) expectedVersion, _ := strconv.Atoi(payload["expectedConfigVersion"]) result, err := svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: operation, Key: payload["key"], InputRef: payload["inputRef"], ExpectedConfigVersion: expectedVersion, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], request.RequestID), }) if err != nil { return bridgeExecutionError(base, err) } base.Status = "queued" base.Result = map[string]string{ "jobId": result.Job.ID, "state": string(result.Job.State), "capability": result.Job.Capability, "targetKey": result.Key, } return base } func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { capability := strings.TrimSpace(payload["capability"]) if capability == "" { base.Status = "error" base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "capability is required"} return base } if !containsString(plugin.RequiredRunCapabilities, capability) || !containsString(plugin.RemoteAccess.RunCapabilities, capability) { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "requested remote capability is not declared by plugin"} return base } declarationKey := strings.TrimSpace(payload["declarationKey"]) if declarationKey == "" { for _, profile := range plugin.RuntimeProfiles.TransportProfiles { if profile.TargetKey == payload["targetKey"] && containsString(profile.Capabilities, capability) { declarationKey = profile.Key break } } } if declarationKey == "" { declarationKey = "legacy-" + string(remoteAdapterKindForCapability(capability)) } timeoutSeconds, _ := strconv.Atoi(payload["timeoutSeconds"]) maxAttempts, _ := strconv.Atoi(payload["maxAttempts"]) inputs := map[string]string{} for key, value := range payload { if strings.HasPrefix(key, "input.") { inputs[strings.TrimPrefix(key, "input.")] = value } } if capability == domain.JobCapabilityRemoteRunRCONCommand && strings.TrimSpace(inputs["command"]) != "" { return svc.executeBridgeSourceRCONCommand(sessionID, base, payload, inputs) } 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) if template.SQLRef != "" { inputs["sqlRef"] = template.SQLRef } } 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) } base.Status = "queued" base.Result = map[string]string{ "jobId": result.RequestID, "state": result.Status, "capability": capability, "targetKey": result.TargetKey, "serverInstanceId": result.ServerInstanceID, "adapterKind": string(result.Kind), } return base } func (svc *CoreService) executeBridgeSourceRCONCommand(sessionID string, base domain.PluginBridgeExecuteResponse, payload map[string]string, inputs map[string]string) domain.PluginBridgeExecuteResponse { dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, domain.SourceRCONCommandRequest{ ServerInstanceID: base.ServerInstanceID, Kind: domain.SourceRCONCommandKindCommand, Command: inputs["command"], IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), }) if err != nil { return bridgeExecutionError(base, err) } targetKey := strings.TrimSpace(payload["targetKey"]) if job, getErr := svc.store.Jobs().Get(dispatch.JobID); getErr == nil && strings.TrimSpace(job.TargetKey) != "" { targetKey = job.TargetKey } base.Status = "queued" base.Result = map[string]string{ "jobId": dispatch.JobID, "state": dispatch.Status, "capability": domain.JobCapabilityRemoteRunRCONCommand, "targetKey": targetKey, "serverInstanceId": dispatch.ServerInstanceID, "adapterKind": string(domain.RemoteAdapterRCON), } 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, TargetOS: defaultBridgeValue(request.Payload["targetOs"], "linux"), TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"), IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID), }) if err != nil { return bridgeExecutionError(base, err) } base.Status = "ok" base.Result = map[string]string{ "distributionId": distribution.ID, "artifactId": distribution.ArtifactID, "checksum": distribution.Checksum, "keyGeneration": strconv.Itoa(distribution.KeyGeneration), "status": string(distribution.Status), } return base } func (svc *CoreService) executeBridgeDependenciesRequest(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { action := defaultBridgeValue(payload["operation"], "check") capability := domain.JobCapabilityDependenciesCheck if action == "install" { capability = domain.JobCapabilityDependenciesInstall } else if action != "check" { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "invalid_dependency_operation", Message: "dependency operation must be check or install"} return base } if !containsString(plugin.RequiredRunCapabilities, capability) { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "dependency capability is not declared by plugin"} return base } job, err := svc.QueueDependencyJobForSession(sessionID, domain.DependencyJobRequest{ ServerInstanceID: instance.ID, ProbeKey: payload["probeKey"], InstallPlanKey: payload["planKey"], PlanDigest: payload["planDigest"], TargetOS: payload["targetOS"], TargetArch: payload["targetArch"], IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), Install: action == "install", }) if err != nil { return bridgeExecutionError(base, err) } base.Status = "queued" base.Result = map[string]string{"jobId": job.ID, "state": string(job.State), "capability": job.Capability, "targetKey": job.TargetKey} return base } func (svc *CoreService) executeBridgeLogsBackfillRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { if !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityLogsBackfill) { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "log backfill capability is not declared by plugin"} return base } job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-logs-backfill", base.RequestID, payload["sourceKey"]), ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityLogsBackfill, TargetKey: defaultBridgeValue(payload["sourceKey"], "logs/default"), InputRef: payload["checkpointRef"], IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"}, }) if err != nil { return bridgeExecutionError(base, err) } base.Status = "queued" base.Result = map[string]string{"jobId": job.ID, "state": string(job.State), "capability": job.Capability, "sourceKey": job.TargetKey} return base } func bridgeExecutionError(base domain.PluginBridgeExecuteResponse, err error) domain.PluginBridgeExecuteResponse { base.Status = "error" base.Error = &domain.PluginBridgeSafeError{Code: "execution_failed", Message: safeBridgeReason(err.Error())} return base } func defaultBridgeValue(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } func safeBridgeReason(reason string) string { reason = strings.TrimSpace(reason) if reason == "" { return "bridge action is not allowed" } for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "api_key=", "apikey="} { if strings.Contains(strings.ToLower(reason), strings.ToLower(forbidden)) { return "bridge action failed safely" } } return reason } func pluginPermissionsFromManifest(permissions []string) domain.PluginPermissions { var aggregate domain.PluginPermissions for _, permission := range permissions { switch permission { case "ai.invoke": aggregate.AI = true case "server.logs.read": aggregate.Logs = true case "server.files.read", "server.files.write": aggregate.Files = true case "server.lifecycle", "server.create": aggregate.Jobs = true case "server.artifacts.read", "server.artifacts.write": aggregate.Artifacts = true case "server.remote.access": aggregate.RemoteAccess = true case "server.run.distribution", "server.dependencies.manage": aggregate.Jobs = true aggregate.Artifacts = true } } return aggregate } func (svc *CoreService) GetGamePlugin(id string) (domain.GamePlugin, error) { return svc.store.GamePlugins().Get(id) } func (svc *CoreService) ListGamePlugins(filter domain.GamePluginFilter) ([]domain.GamePlugin, error) { return svc.store.GamePlugins().List(filter) } func (svc *CoreService) ListMarketplacePlugins(filter domain.PluginMarketplaceFilter) ([]domain.PluginMarketplacePlugin, error) { filter.Keyword = strings.TrimSpace(filter.Keyword) if err := validator.ValidatePluginMarketplaceFilter(filter); err != nil { return nil, err } plugins, err := svc.store.GamePlugins().List(domain.GamePluginFilter{ ServerType: filter.ServerType, Status: filter.Status, }) if err != nil { return nil, err } items := make([]domain.PluginMarketplacePlugin, 0, len(plugins)) for _, plugin := range plugins { projected := marketplacePluginFromGamePlugin(plugin) if filter.Capability != "" && !containsString(projected.Capabilities, filter.Capability) && !containsString(projected.BridgeActions, filter.Capability) { continue } if filter.Keyword != "" && !marketplacePluginMatchesKeyword(projected, filter.Keyword) { continue } // The registry can outlive a manifest contract. Do not let a historical // plugin with removed capabilities make the usable marketplace entries // fail as one invalid response; it is still available through the plugin // registry for an explicit refresh or migration. if err := validator.ValidatePluginMarketplacePlugin(projected); err != nil { continue } items = append(items, projected) } if err := validator.ValidatePluginMarketplacePlugins(items); err != nil { return nil, err } return domain.CopyPluginMarketplacePluginSlice(items), nil } func (svc *CoreService) GetMarketplacePlugin(id string) (domain.PluginMarketplacePlugin, error) { if strings.TrimSpace(id) == "" { return domain.PluginMarketplacePlugin{}, validationError("pluginId is required") } plugin, err := svc.store.GamePlugins().Get(id) if err != nil { return domain.PluginMarketplacePlugin{}, err } projected := marketplacePluginFromGamePlugin(plugin) if err := validator.ValidatePluginMarketplacePlugin(projected); err != nil { return domain.PluginMarketplacePlugin{}, err } return domain.CopyPluginMarketplacePlugin(projected), nil } func (svc *CoreService) SetMarketplacePluginState(id string, action domain.PluginMarketplaceStateAction) (domain.PluginMarketplacePlugin, error) { if strings.TrimSpace(id) == "" { return domain.PluginMarketplacePlugin{}, validationError("pluginId is required") } if err := validator.ValidatePluginMarketplaceStateAction(action); err != nil { return domain.PluginMarketplacePlugin{}, err } plugin, err := svc.store.GamePlugins().Get(id) if err != nil { return domain.PluginMarketplacePlugin{}, err } switch action { case domain.PluginMarketplaceStateActionInstall, domain.PluginMarketplaceStateActionEnable: plugin.Status = domain.GamePluginStatusInstalled case domain.PluginMarketplaceStateActionDisable: plugin.Status = domain.GamePluginStatusDisabled } if err := validator.ValidateGamePlugin(plugin); err != nil { return domain.PluginMarketplacePlugin{}, err } if err := svc.store.GamePlugins().Update(plugin); err != nil { return domain.PluginMarketplacePlugin{}, err } projected := marketplacePluginFromGamePlugin(plugin) if err := validator.ValidatePluginMarketplacePlugin(projected); err != nil { return domain.PluginMarketplacePlugin{}, err } return domain.CopyPluginMarketplacePlugin(projected), nil } func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMarketplacePlugin { plugin = domain.CopyGamePlugin(plugin) return domain.PluginMarketplacePlugin{ ID: plugin.ID, Name: plugin.Name, Description: plugin.Description, Version: plugin.Version, ServerType: plugin.ServerType, ServerDisplayName: plugin.ServerDisplayName, SupportedOS: plugin.SupportedOS, ManifestRef: plugin.ManifestRef, CreateFormSchemaRef: plugin.CreateFormSchemaRef, CreateFields: plugin.CreateFields, Capabilities: plugin.RequiredRunCapabilities, DeclaredPermissions: plugin.DeclaredPermissions, Permissions: plugin.Permissions, LifecycleActions: plugin.LifecycleActions, BridgeActions: plugin.BridgeActions, 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" } return lifecycle } func marketplacePluginMatchesKeyword(plugin domain.PluginMarketplacePlugin, keyword string) bool { keyword = strings.ToLower(strings.TrimSpace(keyword)) if keyword == "" { return true } fields := []string{plugin.ID, plugin.Name, plugin.Description, plugin.ServerType, plugin.ServerDisplayName, plugin.Version} fields = append(fields, plugin.Tags...) fields = append(fields, plugin.Capabilities...) for _, field := range fields { if strings.Contains(strings.ToLower(field), keyword) { return true } } return false } func (svc *CoreService) CreateRunEndpoint(endpoint domain.RunEndpoint) (domain.RunEndpoint, error) { if endpoint.Status == "" { endpoint.Status = domain.RunEndpointStatusOnline } if endpoint.LastHeartbeatAt.IsZero() && (endpoint.Status == domain.RunEndpointStatusOnline || endpoint.Status == domain.RunEndpointStatusDegraded) { endpoint.LastHeartbeatAt = svc.now() } if err := validator.ValidateRunEndpoint(endpoint); err != nil { return domain.RunEndpoint{}, err } if err := svc.store.RunEndpoints().Create(endpoint); err != nil { return domain.RunEndpoint{}, err } return domain.CopyRunEndpoint(endpoint), nil } func (svc *CoreService) GetRunEndpoint(id string) (domain.RunEndpoint, error) { svc.controlMu.Lock() defer svc.controlMu.Unlock() if err := svc.sweepExpiredRunRegistrationsLocked(svc.now()); err != nil { return domain.RunEndpoint{}, err } return svc.store.RunEndpoints().Get(id) } func (svc *CoreService) ListRunEndpoints(filter domain.RunEndpointFilter) ([]domain.RunEndpoint, error) { endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{}) if err != nil { return nil, err } stamp := svc.now() items := make([]domain.RunEndpoint, 0, len(endpoints)) for _, endpoint := range endpoints { if !runEndpointRegistrationCurrentAt(endpoint, stamp) && (endpoint.Status == domain.RunEndpointStatusOnline || endpoint.Status == domain.RunEndpointStatusDegraded) { endpoint.Status = domain.RunEndpointStatusOffline } if filter.Status != "" && endpoint.Status != filter.Status { continue } items = append(items, domain.CopyRunEndpoint(endpoint)) } return items, nil } func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (domain.ServerInstance, error) { plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.ServerInstance{}, fmt.Errorf("get plugin dependency: %w", err) } var endpoint domain.RunEndpoint if strings.TrimSpace(instance.RunEndpointID) != "" { endpoint, err = svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return domain.ServerInstance{}, fmt.Errorf("get run endpoint dependency: %w", err) } } if instance.PluginVersion == "" { instance.PluginVersion = plugin.Version } if instance.State == "" { instance.State = domain.ServerInstanceStateDraft } if instance.ConfigVersion == 0 { instance.ConfigVersion = 1 } stamp := svc.now() if instance.CreatedAt.IsZero() { instance.CreatedAt = stamp } if instance.UpdatedAt.IsZero() { instance.UpdatedAt = stamp } if err := validator.ValidateServerInstance(instance); err != nil { return domain.ServerInstance{}, err } if instance.State != domain.ServerInstanceStateDraft && strings.TrimSpace(instance.RunEndpointID) == "" { return domain.ServerInstance{}, validationError("runEndpointId is required when server is not a draft") } if strings.TrimSpace(instance.RunEndpointID) != "" { if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { return domain.ServerInstance{}, err } } else if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion { return domain.ServerInstance{}, validationError("plugin must be installed and match the server plugin version") } if err := svc.store.ServerInstances().Create(instance); err != nil { return domain.ServerInstance{}, err } return domain.CopyServerInstance(instance), nil } func (svc *CoreService) CreateServerInstanceForSession(sessionID string, instance domain.ServerInstance) (domain.ServerInstance, error) { user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.ServerInstance{}, err } if strings.TrimSpace(instance.OwnerUserID) == "" { instance.OwnerUserID = user.ID } if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID { return domain.ServerInstance{}, ErrForbidden } return svc.CreateServerInstance(instance) } func (svc *CoreService) GetServerInstance(id string) (domain.ServerInstance, error) { return svc.store.ServerInstances().Get(id) } func (svc *CoreService) GetServerInstanceForSession(sessionID string, id string) (domain.ServerInstance, error) { user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.ServerInstance{}, err } instance, err := svc.store.ServerInstances().Get(id) if err != nil { return domain.ServerInstance{}, err } if !canAccessServer(user, instance) { return domain.ServerInstance{}, ErrForbidden } return domain.CopyServerInstance(instance), nil } func (svc *CoreService) UpdateServerInstanceForSession(sessionID string, id string, update domain.ServerInstanceUpdate) (domain.ServerInstance, error) { if err := validator.ValidateServerInstanceUpdate(update); err != nil { return domain.ServerInstance{}, err } user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.ServerInstance{}, err } instance, err := svc.store.ServerInstances().Get(id) if err != nil { return domain.ServerInstance{}, err } if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID { return domain.ServerInstance{}, ErrForbidden } if instance.State == domain.ServerInstanceStateDeleted { return domain.ServerInstance{}, validationError("deleted server instances cannot be edited") } if update.Name != nil { instance.Name = *update.Name } instance.UpdatedAt = svc.now() if err := validator.ValidateStoredServerInstance(instance); err != nil { return domain.ServerInstance{}, err } if err := svc.store.ServerInstances().Update(instance); err != nil { return domain.ServerInstance{}, err } return domain.CopyServerInstance(instance), nil } func (svc *CoreService) ListServerInstances(filter domain.ServerInstanceFilter) ([]domain.ServerInstance, error) { return svc.store.ServerInstances().List(filter) } func (svc *CoreService) ListServerInstancesForSession(sessionID string, filter domain.ServerInstanceFilter) ([]domain.ServerInstance, error) { user, err := svc.GetCurrentUser(sessionID) if err != nil { return nil, err } if !isPlatformAdmin(user) { filter.VisibleToUserID = user.ID } return svc.store.ServerInstances().List(filter) } func (svc *CoreService) ListServerAdministratorCandidates(sessionID string, serverInstanceID string) ([]domain.User, error) { user, instance, err := svc.requireServerOwner(sessionID, serverInstanceID) if err != nil { return nil, err } _ = user users, err := svc.store.Users().List(domain.UserFilter{Status: domain.UserStatusActive}) if err != nil { return nil, err } candidates := make([]domain.User, 0, len(users)) for _, candidate := range users { if candidate.ID == instance.OwnerUserID || containsString(instance.AdminUserIDs, candidate.ID) || isPlatformAdmin(candidate) { continue } candidates = append(candidates, domain.CopyUser(candidate)) } return candidates, nil } func (svc *CoreService) GetPlatformResourceUsage() (domain.PlatformResourceUsage, error) { instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{}) if err != nil { return domain.PlatformResourceUsage{}, err } endpoints, err := svc.ListRunEndpoints(domain.RunEndpointFilter{}) if err != nil { return domain.PlatformResourceUsage{}, err } jobs, err := svc.store.Jobs().List(domain.JobFilter{}) if err != nil { return domain.PlatformResourceUsage{}, err } runningServers := 0 for _, instance := range instances { if instance.State == domain.ServerInstanceStateRunning { runningServers++ } } onlineEndpoints := 0 for _, endpoint := range endpoints { if endpoint.Status == domain.RunEndpointStatusOnline { onlineEndpoints++ } } activeJobs := 0 for _, job := range jobs { if job.State == domain.JobStateQueued || job.State == domain.JobStateAccepted || job.State == domain.JobStateRunning { activeJobs++ } } usage := domain.PlatformResourceUsage{ CPUPercent: clampPercent(float64(runningServers*18 + activeJobs*6 + onlineEndpoints*4)), MemoryPercent: clampPercent(float64(runningServers*22 + onlineEndpoints*8 + len(instances)*3)), DiskPercent: clampPercent(float64(len(instances)*9 + len(jobs)*2)), Source: "platform-derived", CollectedAt: svc.now(), } if err := validator.ValidatePlatformResourceUsage(usage); err != nil { return domain.PlatformResourceUsage{}, err } return domain.CopyPlatformResourceUsage(usage), nil } func (svc *CoreService) ListServerMetricsForSession(sessionID string) ([]domain.ServerMetrics, error) { instances, err := svc.ListServerInstancesForSession(sessionID, domain.ServerInstanceFilter{}) if err != nil { return nil, err } items := make([]domain.ServerMetrics, 0, len(instances)) for _, instance := range instances { items = append(items, svc.latestMetricsForServer(instance)) } if err := validator.ValidateServerMetricsList(items); err != nil { return nil, err } return domain.CopyServerMetricsSlice(items), nil } func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstanceID string) (domain.ServerConfig, error) { instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) if err != nil { return domain.ServerConfig{}, err } config := domain.ServerConfig{ ServerInstanceID: instance.ID, ConfigVersion: instance.ConfigVersion, Format: "properties", Key: "server.properties", Source: "platform-derived", UpdatedAt: instance.UpdatedAt, Checksum: instance.ConfigChecksum, } if config.UpdatedAt.IsZero() { config.UpdatedAt = svc.now() } config.Key = instance.ConfigKey if config.Key == "" { config.Key = "server.properties" } config.Content = instance.ConfigContent if config.Content == "" { config.Content = buildLogicalServerConfig(instance) } if config.Checksum == "" { config.Checksum = validator.BytesChecksum([]byte(config.Content)) } if err := validator.ValidateServerConfig(config); err != nil { return domain.ServerConfig{}, err } return domain.CopyServerConfig(config), nil } func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) { if err := validator.ValidateServerFileReadSnapshotRequest(serverInstanceID, fileKey); err != nil { return domain.DeclaredFileReadSnapshot{}, err } instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) if err != nil { return domain.DeclaredFileReadSnapshot{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.DeclaredFileReadSnapshot{}, err } if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) { return domain.DeclaredFileReadSnapshot{}, ErrForbidden } jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) if err != nil { return domain.DeclaredFileReadSnapshot{}, err } var completed *domain.Job var pending *domain.Job for i := range jobs { job := jobs[i] if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != fileKey { continue } if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" { if completed == nil || newerJob(job, *completed) { copy := job completed = © } continue } if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) { copy := job pending = © } } base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: fileKey} if completed != nil { return domain.DeclaredFileReadSnapshot{ ServerInstanceID: base.ServerInstanceID, PluginID: base.PluginID, Key: base.Key, State: "ready", Content: completed.ExecutionResult.Content, Version: completed.ExecutionResult.Version, Checksum: completed.ExecutionResult.Checksum, SizeBytes: completed.ExecutionResult.SizeBytes, JobID: completed.ID, ReadAt: jobCompletedAt(*completed), }, nil } if pending != nil { base.State = "pending" base.JobID = pending.ID base.Reason = "等待运行端完成文件读取。" return base, nil } base.State = "not-read" base.Reason = "尚未读取此文件。" return base, nil } func declaredFileReadPendingState(state domain.JobState) bool { switch state { case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying: return true default: return false } } func newerJob(left domain.Job, right domain.Job) bool { leftTime, rightTime := jobCompletedAt(left), jobCompletedAt(right) if !leftTime.Equal(rightTime) { return leftTime.After(rightTime) } return left.ID > right.ID } func jobCompletedAt(job domain.Job) time.Time { if !job.TerminalAt.IsZero() { return job.TerminalAt } if !job.UpdatedAt.IsZero() { return job.UpdatedAt } return job.CreatedAt } func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) { if request.Key == "" { request.Key = "server.properties" } if err := validator.ValidateServerConfigDiffRequest(request); err != nil { return domain.ServerConfigDiffPreview{}, err } config, err := svc.GetServerConfigForSession(sessionID, request.ServerInstanceID) if err != nil { return domain.ServerConfigDiffPreview{}, err } if config.ConfigVersion != request.ExpectedConfigVersion { return domain.ServerConfigDiffPreview{}, validationError("expectedConfigVersion must match server instance") } if request.ExpectedChecksum != "" && config.Checksum != request.ExpectedChecksum { return domain.ServerConfigDiffPreview{}, validationError("expectedChecksum must match server config") } if config.Key != request.Key { return domain.ServerConfigDiffPreview{}, validationError("key must match server config") } preview := domain.ServerConfigDiffPreview{ ServerInstanceID: request.ServerInstanceID, ConfigVersion: config.ConfigVersion, Checksum: config.Checksum, Key: request.Key, CurrentContent: config.Content, ProposedContent: request.ProposedContent, ProposedContentInputRef: request.ProposedContentInputRef, Diff: buildConfigDiffLines(config.Content, request.ProposedContent), Source: "platform-review", ReviewedAt: svc.now(), } preview.HasChanges = config.Content != request.ProposedContent return domain.CopyServerConfigDiffPreview(preview), nil } func (svc *CoreService) ApproveServerConfigWriteForSession(sessionID string, approval domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) { if approval.Key == "" { approval.Key = "server.properties" } if approval.ProposedContentInputRef == "" { approval.ProposedContentInputRef = configWriteInputRef(approval.ServerInstanceID, approval.Key, approval.ExpectedConfigVersion) } if err := validator.ValidateServerConfigWriteApproval(approval); err != nil { return domain.ServerConfigWriteDispatch{}, err } preview, err := svc.PreviewServerConfigWriteForSession(sessionID, domain.ServerConfigDiffRequest{ ServerInstanceID: approval.ServerInstanceID, ExpectedConfigVersion: approval.ExpectedConfigVersion, ExpectedChecksum: approval.ExpectedChecksum, Key: approval.Key, ProposedContent: approval.ProposedContent, ProposedContentInputRef: approval.ProposedContentInputRef, }) if err != nil { return domain.ServerConfigWriteDispatch{}, err } if !preview.HasChanges { return domain.ServerConfigWriteDispatch{}, validationError("config diff has no changes") } instance, err := svc.GetServerInstanceForSession(sessionID, approval.ServerInstanceID) if err != nil { return domain.ServerConfigWriteDispatch{}, err } user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.ServerConfigWriteDispatch{}, err } if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "config.write.denied"); err != nil { return domain.ServerConfigWriteDispatch{}, err } job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-config-write", approval.ServerInstanceID, approval.IdempotencyKey), ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityConfigWrite, TargetKey: approval.Key, InputRef: approval.ProposedContentInputRef, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), Content: approval.ProposedContent, ExpectedVersion: approval.ExpectedConfigVersion, ExpectedChecksum: preview.Checksum, MaxReadBytes: 64 * 1024, Deployment: deploymentPlanForDispatch(instance.Deployment)}, IdempotencyKey: approval.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "config write queued"}, }) if err != nil { return domain.ServerConfigWriteDispatch{}, err } return domain.CopyServerConfigWriteDispatch(domain.ServerConfigWriteDispatch{Preview: preview, Job: job, Status: "queued"}), nil } func (svc *CoreService) DispatchFileOperationForSession(sessionID string, request domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error) { if err := validator.ValidateFileOperationDispatchRequest(request); err != nil { return domain.FileOperationDispatchResult{}, err } instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) if err != nil { return domain.FileOperationDispatchResult{}, err } if request.ExpectedConfigVersion > 0 && request.ExpectedConfigVersion != instance.ConfigVersion { return domain.FileOperationDispatchResult{}, validationError("expectedConfigVersion must match server instance") } content := request.Content if request.Operation == domain.FileOperationWrite && content == "" && strings.HasPrefix(request.InputRef, "artifact://") { artifactID := strings.TrimPrefix(request.InputRef, "artifact://") artifact, artifactErr := svc.store.Artifacts().Get(artifactID) if artifactErr != nil { return domain.FileOperationDispatchResult{}, artifactErr } if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != instance.ID || artifact.State != domain.ArtifactStateAvailable { return domain.FileOperationDispatchResult{}, ErrForbidden } } if request.PluginID != "" { plugin, err := svc.store.GamePlugins().Get(request.PluginID) if err != nil { return domain.FileOperationDispatchResult{}, err } if plugin.ID != instance.PluginID { return domain.FileOperationDispatchResult{}, validationError("pluginId must match server instance") } if plugin.Status != domain.GamePluginStatusInstalled { return domain.FileOperationDispatchResult{}, validationError("plugin must be installed") } if (request.Operation == domain.FileOperationList || request.Operation == domain.FileOperationRead) && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") { return domain.FileOperationDispatchResult{}, ErrForbidden } if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") { return domain.FileOperationDispatchResult{}, ErrForbidden } } capability := domain.JobCapabilityFilesRead message := "file read queued" if request.Operation == domain.FileOperationList { capability = domain.JobCapabilityFilesList message = "file list queued" } if request.Operation == domain.FileOperationWrite { capability = domain.JobCapabilityFilesWrite message = "file write queued" } job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-file", request.ServerInstanceID, request.IdempotencyKey), ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: capability, TargetKey: request.Key, InputRef: request.InputRef, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), Content: content, ExpectedVersion: request.ExpectedConfigVersion, ExpectedChecksum: request.ExpectedChecksum, MaxReadBytes: 64 * 1024, Deployment: deploymentPlanForDispatch(instance.Deployment)}, IdempotencyKey: request.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: message}, }) if err != nil { return domain.FileOperationDispatchResult{}, err } return domain.CopyFileOperationDispatchResult(domain.FileOperationDispatchResult{ ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: request.Operation, Key: request.Key, InputRef: request.InputRef, Job: job, Status: "queued", }), nil } func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) { if request.Operation == domain.FileOperationList { if len(workspace.Directories) == 0 { return domain.PluginLogicalFile{}, false, true } for _, directory := range workspace.Directories { if directory.Key == request.Key { return domain.PluginLogicalFile{}, true, true } } return domain.PluginLogicalFile{}, true, false } if len(workspace.Files) == 0 { return domain.PluginLogicalFile{}, false, true } for _, file := range workspace.Files { if file.Key != request.Key { continue } if request.Operation == domain.FileOperationWrite && (file.Kind != "config" || !file.Editable) { return file, true, false } return file, true, true } return domain.PluginLogicalFile{}, true, false } func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string { binding, err := svc.runtimeBindingForServer(serverInstanceID) if err == nil && strings.TrimSpace(binding.ProfileKey) != "" { return binding.ProfileKey } if err != nil && !errors.Is(err, repo.ErrNotFound) { return "default" } instance, instanceErr := svc.store.ServerInstances().Get(serverInstanceID) if instanceErr != nil { return "default" } plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID) if pluginErr != nil { return "default" } if profileKey := lifecycleDefaultProfileKey(instance, plugin, instance.Deployment.ProfileKey); profileKey != "" { return profileKey } return "default" } func (svc *CoreService) latestMetricsForServer(instance domain.ServerInstance) domain.ServerMetrics { samples, err := svc.store.MetricSamples().List(domain.MetricSampleFilter{ServerInstanceID: instance.ID}) if err == nil { var latest domain.MetricSample found := false for i := range samples { sample := samples[i] if sample.RunEndpointID != "" && sample.RunEndpointID != instance.RunEndpointID { continue } if !found || sample.CollectedAt.After(latest.CollectedAt) { latest = sample found = true } } if found { metrics := domain.ServerMetrics{ ServerInstanceID: latest.ServerInstanceID, Online: latest.Online, Source: latest.Source, CollectedAt: latest.CollectedAt, } mergeRecentMetricFields(&metrics, samples, latest.CollectedAt, instance.RunEndpointID) return metrics } } return domain.ServerMetrics{ ServerInstanceID: instance.ID, Online: false, Source: "run-metrics-pending", CollectedAt: svc.now(), } } const recentMetricFieldWindow = 2 * time.Minute // mergeRecentMetricFields keeps independently reported fields visible while // refusing to carry values forward indefinitely when a collector stops. func mergeRecentMetricFields(metrics *domain.ServerMetrics, samples []domain.MetricSample, latestAt time.Time, runEndpointID string) { cutoff := latestAt.Add(-recentMetricFieldWindow) var playerAt, maxPlayersAt, tpsAt, latencyAt, cpuAt, memoryAt, diskAt time.Time for i := range samples { sample := samples[i] if (sample.RunEndpointID != "" && sample.RunEndpointID != runEndpointID) || sample.CollectedAt.Before(cutoff) || sample.CollectedAt.After(latestAt) { continue } if sample.PlayerCount != nil && (metrics.PlayerCount == nil || sample.CollectedAt.After(playerAt)) { metrics.PlayerCount = sample.PlayerCount playerAt = sample.CollectedAt } if sample.MaxPlayers != nil && (metrics.MaxPlayers == nil || sample.CollectedAt.After(maxPlayersAt)) { metrics.MaxPlayers = sample.MaxPlayers maxPlayersAt = sample.CollectedAt } if sample.TPS != nil && (metrics.TPS == nil || sample.CollectedAt.After(tpsAt)) { metrics.TPS = sample.TPS tpsAt = sample.CollectedAt } if sample.LatencyMS != nil && (metrics.LatencyMS == nil || sample.CollectedAt.After(latencyAt)) { metrics.LatencyMS = sample.LatencyMS latencyAt = sample.CollectedAt } if sample.CPUPercent != nil && (metrics.CPUPercent == nil || sample.CollectedAt.After(cpuAt)) { metrics.CPUPercent = sample.CPUPercent cpuAt = sample.CollectedAt } if sample.MemoryPercent != nil && (metrics.MemoryPercent == nil || sample.CollectedAt.After(memoryAt)) { metrics.MemoryPercent = sample.MemoryPercent memoryAt = sample.CollectedAt } if sample.DiskPercent != nil && (metrics.DiskPercent == nil || sample.CollectedAt.After(diskAt)) { metrics.DiskPercent = sample.DiskPercent diskAt = sample.CollectedAt } } } func buildLogicalServerConfig(instance domain.ServerInstance) string { lines := []string{ "# platform logical server config", "server.id=" + instance.ID, "server.name=" + instance.Name, "plugin.id=" + instance.PluginID, "plugin.version=" + instance.PluginVersion, fmt.Sprintf("config.version=%d", instance.ConfigVersion), "state=" + string(instance.State), } return strings.Join(lines, "\n") + "\n" } func buildConfigDiffLines(current string, proposed string) []domain.ConfigDiffLine { currentLines := strings.Split(current, "\n") proposedLines := strings.Split(proposed, "\n") maxLen := len(currentLines) if len(proposedLines) > maxLen { maxLen = len(proposedLines) } lines := make([]domain.ConfigDiffLine, 0, maxLen*2) for i := 0; i < maxLen; i++ { oldExists := i < len(currentLines) newExists := i < len(proposedLines) oldLine := "" newLine := "" if oldExists { oldLine = currentLines[i] } if newExists { newLine = proposedLines[i] } if oldExists && newExists && oldLine == newLine { lines = append(lines, domain.ConfigDiffLine{Kind: "context", OldNumber: i + 1, NewNumber: i + 1, Content: oldLine}) continue } if oldExists { lines = append(lines, domain.ConfigDiffLine{Kind: "removed", OldNumber: i + 1, Content: oldLine}) } if newExists { lines = append(lines, domain.ConfigDiffLine{Kind: "added", NewNumber: i + 1, Content: newLine}) } } return lines } func configWriteInputRef(serverInstanceID string, key string, version int) string { return fmt.Sprintf("input://server-config/%s/%s/v%d", serverInstanceID, strings.ReplaceAll(key, "/", "-"), version) } func jobIDFromParts(prefix string, resourceID string, idempotencyKey string) string { return fmt.Sprintf("%s-%s-%d", prefix, resourceID, stableStringNumber(idempotencyKey)) } func stableStringNumber(value string) int { sum := 0 for _, char := range value { sum = sum*31 + int(char) if sum < 0 { sum = -sum } } return sum } func clampPercent(value float64) float64 { if value < 0 { return 0 } if value > 100 { return 100 } return value } func (svc *CoreService) AddServerAdministrator(sessionID string, serverInstanceID string, userID string) (domain.ServerInstance, error) { _, instance, err := svc.requireServerOwner(sessionID, serverInstanceID) if err != nil { return domain.ServerInstance{}, err } member, err := svc.store.Users().Get(userID) if err != nil { return domain.ServerInstance{}, err } if member.Status != domain.UserStatusActive || isPlatformAdmin(member) || member.ID == instance.OwnerUserID { return domain.ServerInstance{}, ErrForbidden } if !containsString(instance.AdminUserIDs, member.ID) { instance.AdminUserIDs = append(instance.AdminUserIDs, member.ID) instance.UpdatedAt = svc.now() if err := validator.ValidateServerInstance(instance); err != nil { return domain.ServerInstance{}, err } if err := svc.store.ServerInstances().Update(instance); err != nil { return domain.ServerInstance{}, err } } return domain.CopyServerInstance(instance), nil } func (svc *CoreService) RemoveServerAdministrator(sessionID string, serverInstanceID string, userID string) (domain.ServerInstance, error) { _, instance, err := svc.requireServerOwner(sessionID, serverInstanceID) if err != nil { return domain.ServerInstance{}, err } member, err := svc.store.Users().Get(userID) if err != nil { return domain.ServerInstance{}, err } if isPlatformAdmin(member) { return domain.ServerInstance{}, ErrForbidden } nextAdmins := make([]string, 0, len(instance.AdminUserIDs)) for _, adminID := range instance.AdminUserIDs { if adminID != userID { nextAdmins = append(nextAdmins, adminID) } } instance.AdminUserIDs = nextAdmins instance.UpdatedAt = svc.now() if err := validator.ValidateServerInstance(instance); err != nil { return domain.ServerInstance{}, err } if err := svc.store.ServerInstances().Update(instance); err != nil { return domain.ServerInstance{}, err } return domain.CopyServerInstance(instance), nil } func (svc *CoreService) DeleteServerInstanceForSession(sessionID string, serverInstanceID string, request domain.ServerDeletionRequest) (domain.ServerInstance, error) { user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.ServerInstance{}, err } instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) if err != nil { return domain.ServerInstance{}, err } if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID { return domain.ServerInstance{}, ErrForbidden } if strings.TrimSpace(request.Password) == "" { return domain.ServerInstance{}, validationError("password is required") } if !verifyPassword(user.PasswordHash, request.Password) { return domain.ServerInstance{}, forbiddenError("password confirmation failed") } if instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateInstalling { if !request.Force || strings.TrimSpace(request.Confirmation) != ServerDeletionForceConfirmation { return domain.ServerInstance{}, validationError("running or installing server instances require forced-delete confirmation") } } if instance.State == domain.ServerInstanceStateDeleted { return domain.CopyServerInstance(instance), nil } instance.State = domain.ServerInstanceStateDeleted instance.UpdatedAt = svc.now() if err := validator.ValidateStoredServerInstance(instance); err != nil { return domain.ServerInstance{}, err } if err := svc.store.ServerInstances().Update(instance); err != nil { return domain.ServerInstance{}, err } return domain.CopyServerInstance(instance), nil } func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) { if job.State == "" { job.State = domain.JobStateQueued } stamp := svc.now() if job.CreatedAt.IsZero() { job.CreatedAt = stamp } if job.UpdatedAt.IsZero() { job.UpdatedAt = stamp } job = normalizeJobScheduling(job, stamp) if err := validator.ValidateJob(job); err != nil { return domain.Job{}, err } existing, err := svc.store.Jobs().GetByIdempotency(job.RunEndpointID, job.IdempotencyKey) if err == nil { if err := svc.ensureJobLogStreams(existing, stamp); err != nil { return domain.Job{}, err } if !isTerminalJobState(existing.State) { svc.notifyRunJobWaiters(existing.RunEndpointID) } return existing, nil } if !errors.Is(err, repo.ErrNotFound) { return domain.Job{}, err } if job.Capability == domain.JobCapabilityDistributionBuild { if job.RunEndpointID != platformDistributionBuilderEndpointID { return domain.Job{}, validationError("distribution build job must target the platform builder") } } else { endpoint, err := svc.store.RunEndpoints().Get(job.RunEndpointID) if err != nil { return domain.Job{}, fmt.Errorf("get run endpoint dependency: %w", err) } if err := svc.validateRunnableEndpoint(endpoint, job.Capability); err != nil { return domain.Job{}, err } } if job.ServerInstanceID != "" { instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) if err != nil { return domain.Job{}, fmt.Errorf("get server instance dependency: %w", err) } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.Job{}, fmt.Errorf("get server plugin dependency: %w", err) } if err := validateJobServerTarget(job, instance, plugin); err != nil { return domain.Job{}, err } } if err := svc.store.Jobs().Create(job); err != nil { return domain.Job{}, err } if err := svc.ensureJobLogStreams(job, stamp); err != nil { return domain.Job{}, err } svc.notifyRunJobWaiters(job.RunEndpointID) return domain.CopyJob(job), nil } func (svc *CoreService) ensureJobLogStreams(job domain.Job, stamp time.Time) error { lock := svc.logIngestLock(job.ServerInstanceID) lock.Lock() defer lock.Unlock() return svc.ensureJobLogStreamsUnlocked(job, stamp) } func (svc *CoreService) ensureJobLogStreamsUnlocked(job domain.Job, stamp time.Time) error { if strings.TrimSpace(job.ServerInstanceID) == "" || strings.TrimSpace(job.ID) == "" { return nil } streams := []struct { key string source domain.LogStreamSource }{} addStream := func(key string, source domain.LogStreamSource) { key = strings.TrimSpace(key) if key == "" { return } for _, stream := range streams { if stream.key == key { return } } streams = append(streams, struct { key string source domain.LogStreamSource }{key: key, source: source}) } addStream("stdout", domain.LogStreamSourceProcess) addStream("stderr", domain.LogStreamSourceProcess) for _, source := range job.ExecutionInput.LogSources { if source.Kind != "process.stdout" && source.Kind != "process.stderr" { continue } addStream(source.StreamKey, domain.LogStreamSourceProcess) } if job.Capability == domain.JobCapabilityRemoteRunProgram { addStream("management-program.stdout", domain.LogStreamSourceManagementProgram) addStream("management-program.stderr", domain.LogStreamSourceManagementProgram) } if job.Capability == domain.JobCapabilityLogsBackfill && job.ExecutionInput.LogSource != nil && strings.TrimSpace(job.ExecutionInput.LogSource.StreamKey) != "" { addStream(job.ExecutionInput.LogSource.StreamKey, domain.LogStreamSourceFile) } for _, item := range streams { stream := domain.LogStream{ ID: jobLogStreamID(job.ID, item.key), ServerInstanceID: job.ServerInstanceID, Source: item.source, StreamKey: item.key, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", CreatedAt: stamp, UpdatedAt: stamp, } if _, err := svc.createLogStream(stream); err != nil && !errors.Is(err, repo.ErrDuplicate) { return err } } return nil } func jobLogStreamID(jobID string, streamKey string) string { return fmt.Sprintf("job.%s.%s", jobID, streamKey) } func runLogStreamID(runEndpointID string, serverInstanceID string, streamKey string) string { return fmt.Sprintf("run.%s.%s.%s", runEndpointID, serverInstanceID, streamKey) } func runSessionLogStreamID(runEndpointID string, serverInstanceID string, logSessionID string, streamKey string) string { return fmt.Sprintf("run.%s.%s.%s.%s", runEndpointID, serverInstanceID, logSessionID, streamKey) } func (svc *CoreService) GetJob(id string) (domain.Job, error) { job, err := svc.store.Jobs().Get(id) if err != nil { return domain.Job{}, err } return normalizeJobScheduling(job, svc.now()), nil } func (svc *CoreService) ListJobs(filter domain.JobFilter) ([]domain.Job, error) { jobs, err := svc.store.Jobs().List(filter) if err != nil { return nil, err } for i := range jobs { jobs[i] = normalizeJobScheduling(jobs[i], svc.now()) } return jobs, nil } func (svc *CoreService) CreateArtifact(artifact domain.Artifact) (domain.Artifact, error) { if artifact.State == "" { artifact.State = domain.ArtifactStateUploading } stamp := svc.now() if artifact.CreatedAt.IsZero() { artifact.CreatedAt = stamp } if artifact.UpdatedAt.IsZero() { artifact.UpdatedAt = stamp } if err := validator.ValidateArtifact(artifact); err != nil { return domain.Artifact{}, err } if err := svc.store.Artifacts().Create(artifact); err != nil { return domain.Artifact{}, err } return domain.CopyArtifact(artifact), nil } func (svc *CoreService) GetArtifact(id string) (domain.Artifact, error) { return svc.store.Artifacts().Get(id) } func (svc *CoreService) ListArtifacts(filter domain.ArtifactFilter) ([]domain.Artifact, error) { return svc.store.Artifacts().List(filter) } func (svc *CoreService) CreateLogStream(stream domain.LogStream) (domain.LogStream, error) { lock := svc.logIngestLock(stream.ServerInstanceID) lock.Lock() defer lock.Unlock() return svc.createLogStream(stream) } func (svc *CoreService) logIngestLock(serverInstanceID string) *sync.Mutex { hash := uint32(2166136261) for index := 0; index < len(serverInstanceID); index++ { hash ^= uint32(serverInstanceID[index]) hash *= 16777619 } return &svc.logIngestMu[hash%uint32(len(svc.logIngestMu))] } func (svc *CoreService) createLogStream(stream domain.LogStream) (domain.LogStream, error) { instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID) if err != nil { return domain.LogStream{}, fmt.Errorf("get server instance dependency: %w", err) } if instance.State == domain.ServerInstanceStateDeleted { return domain.LogStream{}, validationError("server instance must not be deleted") } stamp := svc.now() if stream.CreatedAt.IsZero() { stream.CreatedAt = stamp } if stream.UpdatedAt.IsZero() { stream.UpdatedAt = stamp } if err := validator.ValidateLogStream(stream); err != nil { return domain.LogStream{}, err } if err := svc.validateLogStreamSession(stream); err != nil { return domain.LogStream{}, err } if err := svc.store.LogStreams().Create(stream); err != nil { return domain.LogStream{}, err } return domain.CopyLogStream(stream), nil } func (svc *CoreService) validateLogStreamSession(stream domain.LogStream) error { if stream.LogSessionID == "" { return nil } streams, err := svc.store.LogStreams().List(domain.LogStreamFilter{ServerInstanceID: stream.ServerInstanceID}) if err != nil { return err } for _, existing := range streams { if existing.LogSessionID == stream.LogSessionID && !existing.SessionStartedAt.Equal(stream.SessionStartedAt) { return validationError("log session metadata conflicts with an existing stream") } } return nil } func (svc *CoreService) GetLogStream(id string) (domain.LogStream, error) { return svc.store.LogStreams().Get(id) } func (svc *CoreService) ListLogStreams(filter domain.LogStreamFilter) ([]domain.LogStream, error) { return svc.store.LogStreams().List(filter) } func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, capability string) error { if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded { return validationError("run endpoint must be online or degraded") } if !svc.runEndpointHeartbeatCurrent(endpoint) { return validationError("run endpoint heartbeat is stale") } if isServerFileCapability(capability) { return nil } if len(validator.MissingCapabilities(endpoint.Capabilities, []string{capability})) > 0 { return validationError("run endpoint missing required capability: " + capability) } return nil } func (svc *CoreService) runEndpointHeartbeatCurrent(endpoint domain.RunEndpoint) bool { return runEndpointRegistrationCurrentAt(endpoint, svc.now()) } func maxInt(a, b int) int { if a > b { return a } return b } func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plugin domain.GamePlugin) error { if instance.State == domain.ServerInstanceStateDeleted { return validationError("server instance must not be deleted") } usesPlatformBuilder := job.Capability == domain.JobCapabilityDistributionBuild && job.RunEndpointID == platformDistributionBuilderEndpointID if instance.RunEndpointID != job.RunEndpointID && !usesPlatformBuilder { return validationError("job runEndpointId must match server instance") } if plugin.ID != instance.PluginID { return validationError("job plugin must match server instance") } if job.Capability != domain.JobCapabilityDistributionBuild && !isServerFileCapability(job.Capability) && !containsString(plugin.RequiredRunCapabilities, job.Capability) { return validationError("plugin missing required capability: " + job.Capability) } return nil } func validationError(violation string) error { return validator.ValidationError{Violations: []string{violation}} } func (svc *CoreService) userIDForSession(sessionID string) (string, error) { session, err := svc.authenticatedSession(sessionID) if err != nil { return "", err } return session.UserID, nil } func userIDFromEmail(email string) string { email = strings.ToLower(strings.TrimSpace(email)) var b strings.Builder b.WriteString("user-") for _, r := range email { switch { case r >= 'a' && r <= 'z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) default: b.WriteByte('-') } } return strings.Trim(b.String(), "-") } func (svc *CoreService) nextUserID(user domain.User) (string, error) { base := userIDFromEmail(user.Email) if base == "user" || base == "" { base = userIDFromEmail(user.DisplayName) } if base == "user" || base == "" { base = "user-account" } if _, err := svc.store.Users().Get(base); errors.Is(err, repo.ErrNotFound) { return base, nil } else if err != nil { return "", err } token, err := randomToken() if err != nil { return "", err } suffix := strings.ToLower(strings.TrimRight(token[:8], "-_")) if suffix == "" { suffix = "generated" } return base + "-" + suffix, nil } func hashPassword(password string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", err } key, err := pbkdf2.Key(sha256.New, password, salt, 120000, 32) if err != nil { return "", err } return "pbkdf2-sha256$120000$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil } func mustHashPassword(password string) string { hash, err := hashPassword(password) if err != nil { panic(err) } return hash } func randomToken() (string, error) { token := make([]byte, 32) if _, err := rand.Read(token); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(token), nil } func randomRunComponentKey() (string, error) { token := make([]byte, 64) if _, err := rand.Read(token); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(token), nil } func verifyPassword(hash string, password string) bool { parts := strings.Split(hash, "$") if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { return false } var iterations int if _, err := fmt.Sscanf(parts[1], "%d", &iterations); err != nil || iterations <= 0 { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[2]) if err != nil { return false } want, err := base64.RawStdEncoding.DecodeString(parts[3]) if err != nil { return false } got, err := pbkdf2.Key(sha256.New, password, salt, iterations, len(want)) if err != nil { return false } return subtle.ConstantTimeCompare(got, want) == 1 }