From a770bc625039d21ad999e8c0e27c912cac2fa184 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 10 Aug 2026 21:12:53 +0800 Subject: [PATCH] Integrate SCUM real ops workflows --- .../tasks.md | 144 ++-- platform/api/log_events_handlers.go | 14 +- platform/api/log_ingest_handlers_test.go | 53 +- platform/api/resource_handlers.go | 78 +- platform/api/resource_handlers_test.go | 177 +--- platform/api/routes.md | 22 +- platform/api/scum_handlers.go | 201 +++++ platform/api/scum_handlers_test.go | 149 ++++ platform/api/source_rcon_handlers.go | 15 +- platform/domain/game_client_bridge.go | 63 +- platform/domain/game_client_bridge_test.go | 9 +- platform/domain/scum_projections.go | 185 ++++ platform/domain/scum_workflows.go | 282 ++++++ platform/dto/resources.go | 72 +- platform/dto/resources_test.go | 38 + platform/dto/scum_projections.go | 81 ++ platform/dto/scum_workflows.go | 243 ++++++ platform/protocol/run-contracts.md | 3 +- platform/protocol/scum-run-integration.md | 67 ++ platform/protocol/server-lifecycle.md | 6 +- platform/repo/file_store.go | 52 +- platform/repo/mysql_store.go | 42 +- platform/repo/resources.go | 211 +++++ platform/repo/scum_projections_test.go | 67 ++ platform/service/game_players.go | 6 + platform/service/resources.go | 14 + platform/service/scum_operations.go | 770 +++++++++++++++++ platform/service/scum_operations_test.go | 273 ++++++ platform/service/scum_projections.go | 803 ++++++++++++++++++ platform/service/scum_projections_test.go | 110 +++ platform/service/scum_workflows.go | 393 +++++++++ platform/service/scum_workflows_test.go | 132 +++ platform/validator/resources.go | 103 ++- platform/validator/resources_test.go | 69 +- .../acceptance/browser-acceptance.mjs | 18 +- platform_web/api/client.test.ts | 115 +-- platform_web/api/client.ts | 102 +-- platform_web/api/contracts.md | 13 +- platform_web/api/sourceRcon.test.ts | 44 - platform_web/api/types.ts | 11 + .../components/ServerLiveOperations.tsx | 368 -------- .../SourceRCONCommandPanel.test.tsx | 18 - .../components/SourceRCONCommandPanel.tsx | 93 -- platform_web/contracts/pages.md | 4 +- platform_web/contracts/pluginPageHost.ts | 36 +- platform_web/contracts/workspace.ts | 11 +- platform_web/nginx.conf | 14 - platform_web/pages/ConsolePages.test.tsx | 71 +- .../pages/PluginPageHostPage.test.tsx | 36 +- platform_web/pages/PluginPageHostPage.tsx | 111 +-- platform_web/pages/ServerDetailPage.test.tsx | 46 +- platform_web/pages/ServerDetailPage.tsx | 640 +------------- platform_web/pages/ServersPage.tsx | 17 +- platform_web/routes/routes.md | 2 +- platform_web/schemas/sourceRcon.test.ts | 25 - platform_web/schemas/sourceRcon.ts | 49 -- platform_web/theme/base.css | 1 + .../scum-server-plugin/features/page.ts | 612 ++++--------- .../examples/scum-server-plugin/manifest.json | 692 ++++++++++++--- .../scum-server-plugin/page-bundle/index.ts | 5 +- ...attribute-855-set.confirmation.schema.json | 13 + ...ayer-attribute-855-set.payload.schema.json | 15 + ...layer-attribute-855-set.result.schema.json | 12 + ...ayer-currency-set.confirmation.schema.json | 14 + .../player-currency-set.payload.schema.json | 12 + .../player-fame-set.confirmation.schema.json | 13 + .../player-fame-set.payload.schema.json | 12 + .../player-notify.confirmation.schema.json | 12 + .../bridge/player-rcon-set.result.schema.json | 12 + .../bridge/queries/SCUM_DB_CONTRACT.md | 14 + .../queries/scum-flags.parameters.schema.json | 12 + .../queries/scum-flags.result.schema.json | 31 + ...scum-player-profile.parameters.schema.json | 13 + .../scum-player-profile.result.schema.json | 34 + .../scum-positions.parameters.schema.json | 11 + .../queries/scum-positions.result.schema.json | 30 + .../scum-squad-members.parameters.schema.json | 11 + .../scum-squad-members.result.schema.json | 29 + .../scum-squads.parameters.schema.json | 11 + .../queries/scum-squads.result.schema.json | 27 + .../scum-vehicles.parameters.schema.json | 12 + .../queries/scum-vehicles.result.schema.json | 31 + .../reward-deliver.confirmation.schema.json | 13 + .../game-plugin.manifest.schema.json | 55 ++ plugins/scripts/validate-manifest.ts | 140 ++- plugins/sdk/index.ts | 42 + plugins/tests/manifest-validation.test.ts | 214 ++++- plugins/tests/scum-feature-module.test.ts | 148 ++-- 88 files changed, 6375 insertions(+), 2719 deletions(-) create mode 100644 platform/api/scum_handlers.go create mode 100644 platform/api/scum_handlers_test.go create mode 100644 platform/domain/scum_projections.go create mode 100644 platform/domain/scum_workflows.go create mode 100644 platform/dto/scum_projections.go create mode 100644 platform/dto/scum_workflows.go create mode 100644 platform/protocol/scum-run-integration.md create mode 100644 platform/repo/scum_projections_test.go create mode 100644 platform/service/scum_operations.go create mode 100644 platform/service/scum_operations_test.go create mode 100644 platform/service/scum_projections.go create mode 100644 platform/service/scum_projections_test.go create mode 100644 platform/service/scum_workflows.go create mode 100644 platform/service/scum_workflows_test.go delete mode 100644 platform_web/api/sourceRcon.test.ts delete mode 100644 platform_web/components/ServerLiveOperations.tsx delete mode 100644 platform_web/components/SourceRCONCommandPanel.test.tsx delete mode 100644 platform_web/components/SourceRCONCommandPanel.tsx delete mode 100644 platform_web/schemas/sourceRcon.test.ts delete mode 100644 platform_web/schemas/sourceRcon.ts create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-attribute-855-set.confirmation.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-attribute-855-set.payload.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-attribute-855-set.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-currency-set.confirmation.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-currency-set.payload.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-fame-set.confirmation.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-fame-set.payload.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-notify.confirmation.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-rcon-set.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.parameters.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.parameters.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.parameters.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.parameters.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.parameters.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.confirmation.schema.json diff --git a/openspec/changes/integrate-real-scum-ops-workflows/tasks.md b/openspec/changes/integrate-real-scum-ops-workflows/tasks.md index 04d9e60..285cf5b 100644 --- a/openspec/changes/integrate-real-scum-ops-workflows/tasks.md +++ b/openspec/changes/integrate-real-scum-ops-workflows/tasks.md @@ -1,101 +1,101 @@ ## 1. Workflow A - Contracts and Safety Model -- [ ] 1.1 Positive prompt: Build the shared SCUM real-data and controlled-operation contract so platform, platform_web, and plugins agree on real observation records, typed write operations, workflow instances, and confirmation states. -- [ ] 1.2 Directional prompt: Update `platform/domain`, `platform/dto`, `platform/validator`, `plugins/sdk`, and manifest schemas using existing job, bridge, protected request, gift, and player-state patterns; verify with Go tests and plugin type/schema tests. -- [ ] 1.3 Boundary prompt: Do not add a `run/` tree, raw SQL browser payloads, host paths, DSNs, sockets, credentials, billing, hosting sales, or unrelated SaaS marketplace behavior. -- [ ] 1.4 Define domain/DTO types for SCUM observations, projection freshness, workflow instances, workflow steps, operation requests, mutation guards, confirmation results, and safe summaries. -- [ ] 1.5 Extend validators to reject arbitrary SQL/RCON/terminal fields in browser/plugin payloads while accepting declared typed query and operation template keys. -- [ ] 1.6 Add tests proving raw SQL, host paths, direct sockets, credentials, and undeclared operation keys are rejected before job creation. +- [x] 1.1 Positive prompt: Build the shared SCUM real-data and controlled-operation contract so platform, platform_web, and plugins agree on real observation records, typed write operations, workflow instances, and confirmation states. +- [x] 1.2 Directional prompt: Update `platform/domain`, `platform/dto`, `platform/validator`, `plugins/sdk`, and manifest schemas using existing job, bridge, protected request, gift, and player-state patterns; verify with Go tests and plugin type/schema tests. +- [x] 1.3 Boundary prompt: Do not add a `run/` tree, raw SQL browser payloads, host paths, DSNs, sockets, credentials, billing, hosting sales, or unrelated SaaS marketplace behavior. +- [x] 1.4 Define domain/DTO types for SCUM observations, projection freshness, workflow instances, workflow steps, operation requests, mutation guards, confirmation results, and safe summaries. +- [x] 1.5 Extend validators to reject arbitrary SQL/RCON/terminal fields in browser/plugin payloads while accepting declared typed query and operation template keys. +- [x] 1.6 Add tests proving raw SQL, host paths, direct sockets, credentials, and undeclared operation keys are rejected before job creation. ## 2. Workflow B - Real SCUM Data Observations -- [ ] 2.1 Positive prompt: Read real current-server SCUM facts from login logs and SCUM.db through the bound run/agent, then persist local projections without fake data. -- [ ] 2.2 Directional prompt: Extend existing log ingest, game player services, remote adapter/query template plumbing, and job result projection in `platform/`; verify with unit tests around stale observations and schema validation. -- [ ] 2.3 Boundary prompt: Do not query SCUM.db from platform_web, do not expose SQL text or machine paths, and do not overwrite last-known-good projections on failed observations. -- [ ] 2.4 Add projection repositories for players, live states, squads, squad members, vehicles, flags, current positions, and observation metadata. -- [ ] 2.5 Wire login/logout log parsing to player/session projection creation and update, preserving source evidence and unknown fields. -- [ ] 2.6 Add typed SCUM.db read-result projection handlers for player profile/economy, squads, squad members, vehicles, flags, and positions. -- [ ] 2.7 Add freshness/stale-state logic with sequence, observedAt, receivedAt, checksum, query key, and server/plugin binding validation. -- [ ] 2.8 Add tests for older observation rejection, failed-query stale marking, user_profile_id vs Steam/user ID separation, and `last_save_time` freshness-only behavior. +- [x] 2.1 Positive prompt: Read real current-server SCUM facts from login logs and SCUM.db through the bound run/agent, then persist local projections without fake data. +- [x] 2.2 Directional prompt: Extend existing log ingest, game player services, remote adapter/query template plumbing, and job result projection in `platform/`; verify with unit tests around stale observations and schema validation. +- [x] 2.3 Boundary prompt: Do not query SCUM.db from platform_web, do not expose SQL text or machine paths, and do not overwrite last-known-good projections on failed observations. +- [x] 2.4 Add projection repositories for players, live states, squads, squad members, vehicles, flags, current positions, and observation metadata. +- [x] 2.5 Wire login/logout log parsing to player/session projection creation and update, preserving source evidence and unknown fields. +- [x] 2.6 Add typed SCUM.db read-result projection handlers for player profile/economy, squads, squad members, vehicles, flags, and positions. +- [x] 2.7 Add freshness/stale-state logic with sequence, observedAt, receivedAt, checksum, query key, and server/plugin binding validation. +- [x] 2.8 Add tests for older observation rejection, failed-query stale marking, user_profile_id vs Steam/user ID separation, and `last_save_time` freshness-only behavior. ## 3. Workflow C - SCUM Plugin Read Templates -- [ ] 3.1 Positive prompt: Declare SCUM plugin read templates for users, squads, vehicles, flags, and coordinates using real SCUM.db tables and typed result schemas. -- [ ] 3.2 Directional prompt: Update `plugins/examples/scum-server-plugin/manifest.json`, `schemas/bridge/queries`, plugin tests, and manifest validation while keeping query declarations safe and route-scoped. -- [ ] 3.3 Boundary prompt: Do not copy scum_robot management-library fields as SCUM.db facts, do not infer missing fields, and do not let pages submit SQL text. -- [ ] 3.4 Add query templates for player profile/economy/position using `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, and `bank_account_registry_currencies`. -- [ ] 3.5 Add query templates for squads and members using `squad`, `squad_member`, and `user_profile`. -- [ ] 3.6 Add query templates for vehicles using `vehicle_spawner` and `entity`, with unknown/fallback vehicle labels when mapping is absent. -- [ ] 3.7 Add query templates for flags using `base_element`, `user_profile`, `squad_member`, and `squad` where available. -- [ ] 3.8 Add schema and validation tests for row bounds, typed result shape, page/template binding, and route permissions. +- [x] 3.1 Positive prompt: Declare SCUM plugin read templates for users, squads, vehicles, flags, and coordinates using real SCUM.db tables and typed result schemas. +- [x] 3.2 Directional prompt: Update `plugins/examples/scum-server-plugin/manifest.json`, `schemas/bridge/queries`, plugin tests, and manifest validation while keeping query declarations safe and route-scoped. +- [x] 3.3 Boundary prompt: Do not copy scum_robot management-library fields as SCUM.db facts, do not infer missing fields, and do not let pages submit SQL text. +- [x] 3.4 Add query templates for player profile/economy/position using `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, and `bank_account_registry_currencies`. +- [x] 3.5 Add query templates for squads and members using `squad`, `squad_member`, and `user_profile`. +- [x] 3.6 Add query templates for vehicles using `vehicle_spawner` and `entity`, with unknown/fallback vehicle labels when mapping is absent. +- [x] 3.7 Add query templates for flags using `base_element`, `user_profile`, `squad_member`, and `squad` where available. +- [x] 3.8 Add schema and validation tests for row bounds, typed result shape, page/template binding, and route permissions. ## 4. Workflow D - Controlled RCON Operations -- [ ] 4.1 Positive prompt: Support safe fame, normal currency, gold, player notification, and reward command workflows through typed RCON operations where SCUM supports commands. -- [ ] 4.2 Directional prompt: Build on existing source RCON/protected RCON dispatch and game client bridge command approval paths; verify with service tests for approval, redaction, and confirmation status. -- [ ] 4.3 Boundary prompt: Do not restore a product terminal or arbitrary RCON input box; do not mark queued commands as delivered or successful. -- [ ] 4.4 Declare typed RCON operation templates for `player.fame.set`, `player.currency.normal.set`, `player.currency.gold.set`, `player.notify`, and command-backed reward delivery. -- [ ] 4.5 Add platform services to create, approve, dispatch, and reconcile typed RCON operation requests with idempotency and audit references. -- [ ] 4.6 Add read-after-write confirmation using follow-up SCUM.db observation queries or typed companion results. -- [ ] 4.7 Add tests for permission denial, protected text redaction, command unknown state, confirmation failure, and duplicate prevention. +- [x] 4.1 Positive prompt: Support safe fame, normal currency, gold, player notification, and reward command workflows through typed RCON operations where SCUM supports commands. +- [x] 4.2 Directional prompt: Build on existing source RCON/protected RCON dispatch and game client bridge command approval paths; verify with service tests for approval, redaction, and confirmation status. +- [x] 4.3 Boundary prompt: Do not restore a product terminal or arbitrary RCON input box; do not mark queued commands as delivered or successful. +- [x] 4.4 Declare typed RCON operation templates for `player.fame.set`, `player.currency.normal.set`, `player.currency.gold.set`, `player.notify`, and command-backed reward delivery. +- [x] 4.5 Add platform services to create, approve, dispatch, and reconcile typed RCON operation requests with idempotency and audit references. +- [x] 4.6 Add read-after-write confirmation using follow-up SCUM.db observation queries or typed companion results. +- [x] 4.7 Add tests for permission denial, protected text redaction, command unknown state, confirmation failure, and duplicate prevention. ## 5. Workflow E - Controlled DB Mutations -- [ ] 5.1 Positive prompt: Support database-only player state edits such as field `855` through typed mutation templates with maintenance/offline safety and readback confirmation. -- [ ] 5.2 Directional prompt: Extend operation template schemas, platform operation services, and run job execution contracts without storing raw mutation SQL in browser-visible records; verify with stale-write and confirmation tests. -- [ ] 5.3 Boundary prompt: Do not use DB mutation for fame/currency when RCON exists, do not write more than declared row bounds, and do not proceed without before-value guards. -- [ ] 5.4 Add mutation template declarations for DB-only player fields with field key, table/identity mapping metadata, allowed range, safety level, confirmation query, and max affected rows. -- [ ] 5.5 Add platform approval flow requiring current projection, `before` match, offline/maintenance window, backup/snapshot evidence, and platform-admin approval. -- [ ] 5.6 Add run job result validation for affected rows, mutation checksum, confirmation rows, and unknown execution states. -- [ ] 5.7 Add tests for online-player blocking, missing maintenance window, stale before-value, over-bound affected rows, and successful confirmation. +- [x] 5.1 Positive prompt: Support database-only player state edits such as field `855` through typed mutation templates with maintenance/offline safety and readback confirmation. +- [x] 5.2 Directional prompt: Extend operation template schemas, platform operation services, and run job execution contracts without storing raw mutation SQL in browser-visible records; verify with stale-write and confirmation tests. +- [x] 5.3 Boundary prompt: Do not use DB mutation for fame/currency when RCON exists, do not write more than declared row bounds, and do not proceed without before-value guards. +- [x] 5.4 Add mutation template declarations for DB-only player fields with field key, table/identity mapping metadata, allowed range, safety level, confirmation query, and max affected rows. +- [x] 5.5 Add platform approval flow requiring current projection, `before` match, offline/maintenance window, backup/snapshot evidence, and platform-admin approval. +- [x] 5.6 Add run job result validation for affected rows, mutation checksum, confirmation rows, and unknown execution states. +- [x] 5.7 Add tests for online-player blocking, missing maintenance window, stale before-value, over-bound affected rows, and successful confirmation. ## 6. Workflow F - Sequential Workflow Engine -- [ ] 6.1 Positive prompt: Create a SCUM workflow queue that can run real-data refreshes and controlled operations one by one with dependency tracking. -- [ ] 6.2 Directional prompt: Add platform workflow domain/repo/service/API code near existing job/game-client/gift/player-state services; keep run execution delegated through existing job channels. -- [ ] 6.3 Boundary prompt: Do not replace the generic run job scheduler, do not block control heartbeat/log/artifact channels, and do not expose protected payload text in workflow status. -- [ ] 6.4 Implement workflow instance and step state transitions: draft, queued, running, waiting, blocked, confirming, confirmed, failed, unknown, cancelled. -- [ ] 6.5 Implement per-server sequential dispatch for state-changing steps and bounded concurrency for read-only observation steps. -- [ ] 6.6 Add workflow templates for bootstrap real data, player refresh, world refresh, player correction, gift delivery, territory audit, vehicle audit, AI assist, and product cleanup. -- [ ] 6.7 Add idempotency, fencing, retry, confirmation-before-retry, and blocker-safe-summary behavior. -- [ ] 6.8 Add tests for ordered execution, dependency blocking, run-unavailable blocking, retry without duplicate effects, and terminal status projection. +- [x] 6.1 Positive prompt: Create a SCUM workflow queue that can run real-data refreshes and controlled operations one by one with dependency tracking. +- [x] 6.2 Directional prompt: Add platform workflow domain/repo/service/API code near existing job/game-client/gift/player-state services; keep run execution delegated through existing job channels. +- [x] 6.3 Boundary prompt: Do not replace the generic run job scheduler, do not block control heartbeat/log/artifact channels, and do not expose protected payload text in workflow status. +- [x] 6.4 Implement workflow instance and step state transitions: draft, queued, running, waiting, blocked, confirming, confirmed, failed, unknown, cancelled. +- [x] 6.5 Implement per-server sequential dispatch for state-changing steps and bounded concurrency for read-only observation steps. +- [x] 6.6 Add workflow templates for bootstrap real data, player refresh, world refresh, player correction, gift delivery, territory audit, vehicle audit, AI assist, and product cleanup. +- [x] 6.7 Add idempotency, fencing, retry, confirmation-before-retry, and blocker-safe-summary behavior. +- [x] 6.8 Add tests for ordered execution, dependency blocking, run-unavailable blocking, retry without duplicate effects, and terminal status projection. ## 7. Workflow G - Product APIs and Projection Views -- [ ] 7.1 Positive prompt: Expose safe SCUM APIs for projection-backed users, squads, vehicles, flags, map overlays, gifts, operations, workflows, and AI drafts. -- [ ] 7.2 Directional prompt: Add `platform/api`, `platform/dto`, and service handlers following existing resource handler patterns; verify authorization tests and DTO round trips. -- [ ] 7.3 Boundary prompt: Do not expose raw logs, terminal input, arbitrary config file editing, generic operation-history APIs, SQL text, DSNs, host paths, or raw protected request payloads. -- [ ] 7.4 Add list/detail APIs for SCUM players, squads, squad members, vehicles, flags, current positions, map overlays, and observation freshness. -- [ ] 7.5 Add workflow APIs for creating refresh/correction/gift/audit/AI workflows, listing workflow status, approving required steps, cancelling safe pending steps, and reading audit-safe summaries. -- [ ] 7.6 Update or remove legacy product APIs for raw logs, management terminal, raw config workbench, and generic operation history. -- [ ] 7.7 Add authorization tests for operator vs platform-admin actions and raw endpoint removal/denial. +- [x] 7.1 Positive prompt: Expose safe SCUM APIs for projection-backed users, squads, vehicles, flags, map overlays, gifts, operations, workflows, and AI drafts. +- [x] 7.2 Directional prompt: Add `platform/api`, `platform/dto`, and service handlers following existing resource handler patterns; verify authorization tests and DTO round trips. +- [x] 7.3 Boundary prompt: Do not expose raw logs, terminal input, arbitrary config file editing, generic operation-history APIs, SQL text, DSNs, host paths, or raw protected request payloads. +- [x] 7.4 Add list/detail APIs for SCUM players, squads, squad members, vehicles, flags, current positions, map overlays, and observation freshness. +- [x] 7.5 Add workflow APIs for creating refresh/correction/gift/audit/AI workflows, listing workflow status, approving required steps, cancelling safe pending steps, and reading audit-safe summaries. +- [x] 7.6 Update or remove legacy product APIs for raw logs, management terminal, raw config workbench, and generic operation history. +- [x] 7.7 Add authorization tests for operator vs platform-admin actions and raw endpoint removal/denial. ## 8. Workflow H - SCUM Product Surfaces -- [ ] 8.1 Positive prompt: Replace SCUM plugin pages with real projection-backed user management, squad management, realtime map, vehicle/flag management, gift management, workflow status, and AI assistant surfaces. -- [ ] 8.2 Directional prompt: Update `platform_web` contracts and SCUM plugin feature pages while preserving the black mecha and crystal-moonlight theme system; run typecheck and frontend tests. -- [ ] 8.3 Boundary prompt: Do not introduce generic SaaS cards, page-local fixed decoration spans, raw terminal/log/config/history panels, or fake map/sample data. -- [ ] 8.4 Build user management UI showing identities, sessions, projection source, squad, fame, balances, coordinates, freshness, and typed edit workflow launchers. -- [ ] 8.5 Build squad/flag UI showing rosters, ranks, leaders, flags, ownership confidence, stale state, and refresh/audit workflow controls. -- [ ] 8.6 Build realtime map UI using local projections for players, vehicles, flags, squads, timestamps, stale status, and refresh controls. -- [ ] 8.7 Build gift UI for catalog versions, schedules, eligibility, claims, delivery workflow status, confirmation, unknown-state reconciliation, and player notifications. -- [ ] 8.8 Keep AI assistant UI for typed config diffs and workflow drafts, wired to approval flows rather than raw file editing. -- [ ] 8.9 Remove raw logs, management terminal, raw config workbench, and operation history routes from server detail navigation and plugin workspace contracts. +- [x] 8.1 Positive prompt: Replace SCUM plugin pages with real projection-backed user management, squad management, realtime map, vehicle/flag management, gift management, workflow status, and AI assistant surfaces. +- [x] 8.2 Directional prompt: Update `platform_web` contracts and SCUM plugin feature pages while preserving the black mecha and crystal-moonlight theme system; run typecheck and frontend tests. +- [x] 8.3 Boundary prompt: Do not introduce generic SaaS cards, page-local fixed decoration spans, raw terminal/log/config/history panels, or fake map/sample data. +- [x] 8.4 Build user management UI showing identities, sessions, projection source, squad, fame, balances, coordinates, freshness, and typed edit workflow launchers. +- [x] 8.5 Build squad/flag UI showing rosters, ranks, leaders, flags, ownership confidence, stale state, and refresh/audit workflow controls. +- [x] 8.6 Build realtime map UI using local projections for players, vehicles, flags, squads, timestamps, stale status, and refresh controls. +- [x] 8.7 Build gift UI for catalog versions, schedules, eligibility, claims, delivery workflow status, confirmation, unknown-state reconciliation, and player notifications. +- [x] 8.8 Keep AI assistant UI for typed config diffs and workflow drafts, wired to approval flows rather than raw file editing. +- [x] 8.9 Remove raw logs, management terminal, raw config workbench, and operation history routes from server detail navigation and plugin workspace contracts. ## 9. Workflow I - Run Integration Contract -- [ ] 9.1 Positive prompt: Define the browser-repo contract expected from the independent run repository for SCUM.db reads, RCON writes, DB mutations, confirmation reads, and log ingestion. -- [ ] 9.2 Directional prompt: Document and test platform/plugin protocol expectations in this repo; keep executable run implementation for `git@git.npc0.com:admin343/run.git` outside this repository. -- [ ] 9.3 Boundary prompt: Do not add run source code here, do not require deployment target/run endpoint at server creation time, and do not leak component auth keys or host paths. -- [ ] 9.4 Add protocol DTOs or contract docs for read observation inputs/results, mutation inputs/results, confirmation payloads, schema probes, and safe errors. -- [ ] 9.5 Add compatibility tests/mocks proving platform can process run-style read/mutation/RCON results without real run code in this repository. -- [ ] 9.6 Document external run tasks needed to execute SCUM.db query templates and controlled mutation templates on the service machine. +- [x] 9.1 Positive prompt: Define the browser-repo contract expected from the independent run repository for SCUM.db reads, RCON writes, DB mutations, confirmation reads, and log ingestion. +- [x] 9.2 Directional prompt: Document and test platform/plugin protocol expectations in this repo; keep executable run implementation for `git@git.npc0.com:admin343/run.git` outside this repository. +- [x] 9.3 Boundary prompt: Do not add run source code here, do not require deployment target/run endpoint at server creation time, and do not leak component auth keys or host paths. +- [x] 9.4 Add protocol DTOs or contract docs for read observation inputs/results, mutation inputs/results, confirmation payloads, schema probes, and safe errors. +- [x] 9.5 Add compatibility tests/mocks proving platform can process run-style read/mutation/RCON results without real run code in this repository. +- [x] 9.6 Document external run tasks needed to execute SCUM.db query templates and controlled mutation templates on the service machine. ## 10. Workflow J - Verification, Cleanup, Commit -- [ ] 10.1 Positive prompt: Verify the full SCUM integration change with backend, frontend, plugin, structure, and OpenSpec checks before committing. -- [ ] 10.2 Directional prompt: Run targeted tests during implementation and final commands: `(cd platform && go test ./...)`, `(cd platform_web && npm run typecheck && npm run test && npm run build)`, `(cd plugins && npm run typecheck && npm run test && npm run validate:manifest)`, `scripts/check-structure.sh`, and `openspec validate integrate-real-scum-ops-workflows --strict`. -- [ ] 10.3 Boundary prompt: Do not mark tasks complete without verification evidence, do not stage unrelated worktree changes, and do not push if required verification or credentials fail. -- [ ] 10.4 Sweep for forbidden raw SQL/RCON/terminal/config/history product surfaces and unsafe browser-visible fields. -- [ ] 10.5 Update documentation or comments only where they clarify new contracts and workflow behavior. -- [ ] 10.6 Stage only files changed for this task, commit on `main`, and push to the configured remote after verification succeeds. +- [x] 10.1 Positive prompt: Verify the full SCUM integration change with backend, frontend, plugin, structure, and OpenSpec checks before committing. +- [x] 10.2 Directional prompt: Run targeted tests during implementation and final commands: `(cd platform && go test ./...)`, `(cd platform_web && npm run typecheck && npm run test && npm run build)`, `(cd plugins && npm run typecheck && npm run test && npm run validate:manifest)`, `scripts/check-structure.sh`, and `openspec validate integrate-real-scum-ops-workflows --strict`. +- [x] 10.3 Boundary prompt: Do not mark tasks complete without verification evidence, do not stage unrelated worktree changes, and do not push if required verification or credentials fail. +- [x] 10.4 Sweep for forbidden raw SQL/RCON/terminal/config/history product surfaces and unsafe browser-visible fields. +- [x] 10.5 Update documentation or comments only where they clarify new contracts and workflow behavior. +- [x] 10.6 Stage only files changed for this task, commit on `main`, and push to the configured remote after verification succeeds. diff --git a/platform/api/log_events_handlers.go b/platform/api/log_events_handlers.go index 2c2877e..c234e09 100644 --- a/platform/api/log_events_handlers.go +++ b/platform/api/log_events_handlers.go @@ -20,19 +20,7 @@ const ( logEventHeartbeatInterval = 15 * time.Second ) -// serverLogEvents godoc -// @Summary Stream server log events -// @Description Streams safe server log entries over Server-Sent Events. Durable cursor query remains available for history and reconnect repair. -// @Tags logs -// @Produce text/event-stream -// @Param id path string true "Server instance ID" -// @Param historyLimit query int false "Total recent entries to replay across this server's streams" -// @Success 200 {string} string "event-stream" -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 404 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/logs/events [get] +// serverLogEvents is kept as legacy service plumbing but is not registered as a browser product route. func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) diff --git a/platform/api/log_ingest_handlers_test.go b/platform/api/log_ingest_handlers_test.go index fdaa84a..a5bbd20 100644 --- a/platform/api/log_ingest_handlers_test.go +++ b/platform/api/log_ingest_handlers_test.go @@ -1,10 +1,7 @@ package api import ( - "bufio" "net/http" - "net/http/httptest" - "strings" "testing" "time" @@ -44,24 +41,7 @@ func TestLogEventsSSEReplaysHistory(t *testing.T) { batch := validLogBatchRequest(t, hello.SessionToken, 1, 2) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK) - server := httptest.NewServer(router) - defer server.Close() - client := server.Client() - client.Timeout = 2 * time.Second - response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2") - if err != nil { - t.Fatalf("open log event stream: %v", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") { - t.Fatalf("unexpected event stream response: status=%d content-type=%q", response.StatusCode, response.Header.Get("Content-Type")) - } - body := readSSEUntil(t, response, "event: ready") - for _, fragment := range []string{"event: stream", "event: log", `"streamId":"log-1"`, `"seq":1`, `"seq":2`} { - if !strings.Contains(body, fragment) { - t.Fatalf("expected SSE body to contain %q, got:\n%s", fragment, body) - } - } + assertStatus(t, performJSON(t, router, http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=2", nil), http.StatusNotFound) } func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) { @@ -74,22 +54,7 @@ func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) { assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-1", "stdout", 1, 2, 0)), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-2", "stderr", 1, 2, 10)), http.StatusOK) - server := httptest.NewServer(router) - defer server.Close() - client := server.Client() - client.Timeout = 2 * time.Second - response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2") - if err != nil { - t.Fatalf("open log event stream: %v", err) - } - defer response.Body.Close() - body := readSSEUntil(t, response, "event: ready") - if strings.Contains(body, `"streamId":"log-1"`) { - t.Fatalf("expected no history entries from older stream, got:\n%s", body) - } - if strings.Count(body, `"streamId":"log-2"`) != 2 || !strings.Contains(body, `"seq":2`) { - t.Fatalf("expected newest two entries from stream log-2, got:\n%s", body) - } + assertStatus(t, performJSON(t, router, http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=2", nil), http.StatusNotFound) } func TestLogIngestAPIDuplicateAndErrors(t *testing.T) { @@ -134,20 +99,6 @@ func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunContro return hello } -func readSSEUntil(t *testing.T, response *http.Response, marker string) string { - t.Helper() - reader := bufio.NewReader(response.Body) - var body strings.Builder - for !strings.Contains(body.String(), marker) { - line, err := reader.ReadString('\n') - if err != nil { - t.Fatalf("read event stream: %v\n%s", err, body.String()) - } - body.WriteString(line) - } - return body.String() -} - func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest { return validLogBatchRequestForStream(t, sessionToken, "log-1", "stdout", firstSeq, lastSeq, 0) } diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 30f2666..2ead657 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -72,7 +72,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment) mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy) mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters) - mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands) mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate) mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload) mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset) @@ -104,16 +103,19 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions) mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants) mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/squad-members", h.serverSCUMSquadMembers) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/flags", h.serverSCUMFlags) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/positions", h.serverSCUMPositions) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/operations", h.serverSCUMOperations) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/operations/{operationId}/approve", h.serverSCUMOperationApprove) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/workflows", h.serverSCUMWorkflows) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/workflow-steps", h.serverSCUMWorkflowSteps) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) - mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs) - mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents) - mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill) - mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverDeclaredFileReadSnapshot) - mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff) - mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove) - mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates) mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail) @@ -1407,18 +1409,7 @@ func (h *coreHandlers) serverInstanceMetrics(w http.ResponseWriter, r *http.Requ writeJSON(w, http.StatusOK, dto.ServerMetricsListFromDomain(metrics)) } -// serverInstanceConfig godoc -// @Summary Read server configuration -// @Description Returns logical server configuration content for an authorized server instance without exposing run internals. -// @Tags server-instances -// @Produce json -// @Param id path string true "Server instance ID" -// @Success 200 {object} dto.ServerConfigResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 404 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/config [get] +// serverInstanceConfig is kept as legacy service plumbing but is not registered as a browser product route. func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) @@ -1432,20 +1423,7 @@ func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config)) } -// serverDeclaredFileReadSnapshot godoc -// @Summary Read the latest declared file snapshot -// @Description Returns a redacted bounded result only for an authorized plugin-declared logical file key. -// @Tags server-instances -// @Produce json -// @Param id path string true "Server instance ID" -// @Param key query string true "Plugin-declared logical file key" -// @Success 200 {object} dto.DeclaredFileReadSnapshotResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 404 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/files/read-snapshot [get] +// serverDeclaredFileReadSnapshot is kept as legacy service plumbing but is not registered as a browser product route. func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) @@ -1459,21 +1437,7 @@ func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r * writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot)) } -// serverInstanceConfigDiff godoc -// @Summary Preview server config diff -// @Description Compares current logical server config with proposed content without dispatching a write job. -// @Tags server-instances -// @Accept json -// @Produce json -// @Param id path string true "Server instance ID" -// @Param body body dto.ServerConfigDiffPreviewRequest true "Config diff preview request" -// @Success 200 {object} dto.ServerConfigDiffPreviewResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 404 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/config/diff [post] +// serverInstanceConfigDiff is kept as legacy service plumbing but is not registered as a browser product route. func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) @@ -1492,21 +1456,7 @@ func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusOK, dto.ServerConfigDiffPreviewFromDomain(preview)) } -// serverInstanceConfigApprove godoc -// @Summary Approve server config write -// @Description Validates a reviewed config diff and queues a scoped config.write run job without exposing host paths or raw credentials. -// @Tags server-instances -// @Accept json -// @Produce json -// @Param id path string true "Server instance ID" -// @Param body body dto.ServerConfigWriteApprovalRequest true "Config write approval request" -// @Success 202 {object} dto.ServerConfigWriteDispatchResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 404 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/config/approve [post] +// serverInstanceConfigApprove is kept as legacy service plumbing but is not registered as a browser product route. func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index d7459fc..648fbbf 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -189,18 +189,9 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) { t.Fatalf("expected no metrics for other user, got %+v", otherMetrics) } - configRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession) - assertStatus(t, configRecorder, http.StatusOK) - config := decodeBody[dto.ServerConfigResponse](t, configRecorder) - if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics API Server") { - t.Fatalf("unexpected config response: %+v", config) - } - for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} { - if strings.Contains(configRecorder.Body.String(), forbidden) { - t.Fatalf("config response exposed forbidden fragment %q: %s", forbidden, configRecorder.Body.String()) - } - } - assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusForbidden, errorCodeForbidden) + _ = instance + assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession), http.StatusNotFound) + assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusNotFound) } func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { @@ -235,52 +226,9 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { State: domain.ServerInstanceStateRunning, }, ownerSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession) - config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession) - proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1) - - previewRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ - ExpectedConfigVersion: config.ConfigVersion, - Key: config.Key, - ProposedContent: proposed, - }, ownerSession) - assertStatus(t, previewRecorder, http.StatusOK) - preview := decodeBody[dto.ServerConfigDiffPreviewResponse](t, previewRecorder) - if !preview.HasChanges || preview.Source != "platform-review" || preview.ServerInstanceID != instance.ID { - t.Fatalf("unexpected preview: %+v", preview) - } - jobsAfterPreview := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-config-api", ownerSession) - if jobsAfterPreview.Count != 0 { - t.Fatalf("preview must not create jobs: %+v", jobsAfterPreview) - } - - approveRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ - ExpectedConfigVersion: config.ConfigVersion, - Key: config.Key, - ProposedContent: proposed, - IdempotencyKey: "idem-config-api", - }, ownerSession) - assertStatus(t, approveRecorder, http.StatusAccepted) - dispatch := decodeBody[dto.ServerConfigWriteDispatchResponse](t, approveRecorder) - if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != config.Key || dispatch.Job.InputRef == "" { - t.Fatalf("unexpected approval dispatch: %+v", dispatch) - } - for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} { - if strings.Contains(approveRecorder.Body.String(), forbidden) { - t.Fatalf("approval response exposed forbidden fragment %q: %s", forbidden, approveRecorder.Body.String()) - } - } - - assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ - ExpectedConfigVersion: config.ConfigVersion + 1, - Key: config.Key, - ProposedContent: proposed, - }, ownerSession), http.StatusBadRequest, errorCodeValidation) - assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ - ExpectedConfigVersion: config.ConfigVersion, - Key: config.Key, - ProposedContent: proposed, - IdempotencyKey: "idem-forbidden-api", - }, otherSession), http.StatusForbidden, errorCodeForbidden) + assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-config-api/config", "", ownerSession), http.StatusNotFound) + assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n"}, ownerSession), http.StatusNotFound) + assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n", IdempotencyKey: "idem-config-api"}, otherSession), http.StatusNotFound) assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{ ServerInstanceID: "server-config-api", Operation: domain.FileOperationRead, @@ -304,42 +252,7 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) - postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ - ID: "user-owner-file-snapshot-api", - DisplayName: "File Snapshot API Owner", - Email: "owner-file-snapshot-api@example.test", - Roles: []string{"server-owner"}, - Password: "secret-password", - }, adminSession) - postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ - ID: "user-other-file-snapshot-api", - DisplayName: "File Snapshot API Other", - Email: "other-file-snapshot-api@example.test", - Roles: []string{"server-admin"}, - Password: "secret-password", - }, adminSession) - ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-file-snapshot-api@example.test", Password: "secret-password"}).SessionID - otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-file-snapshot-api@example.test", Password: "secret-password"}).SessionID - - pluginRequest := validGamePluginRequest() - pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesRead) - pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"} - pluginRequest.Permissions.Files = true - pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{ - DefaultDirectoryKey: "scum-config", - Directories: []dto.PluginLogicalDirectoryBody{ - {Key: "scum-config", Label: "服务器配置", Scope: "config"}, - {Key: "scum-logs", Label: "日志文件", Scope: "logs"}, - }, - Files: []dto.PluginLogicalFileBody{ - {Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true}, - {Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"}, - }, - ConfigFields: []dto.PluginConfigFieldBody{ - {Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"}, - }, - } - postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-file-snapshot-api", @@ -347,57 +260,9 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) { RunEndpointID: "run-local", Name: "File Snapshot API Server", State: domain.ServerInstanceStateRunning, - }, ownerSession) + }, adminSession) - snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession) - if snapshot.State != "not-read" || snapshot.Content != "" { - t.Fatalf("expected not-read snapshot, got %+v", snapshot) - } - assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=logs/latest.log", "", ownerSession), http.StatusBadRequest, errorCodeValidation) - assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", otherSession), http.StatusForbidden, errorCodeForbidden) - - postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ - ID: "job-file-snapshot-api-read", - ServerInstanceID: instance.ID, - RunEndpointID: "run-local", - Capability: domain.JobCapabilityFilesRead, - TargetKey: "scum-server-settings", - IdempotencyKey: "idem-file-snapshot-api-read", - }) - helloRequest := validRunControlHelloRequest() - helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesRead) - hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest)) - claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{ - RunEndpointID: "run-local", - SessionToken: hello.SessionToken, - Capabilities: []string{domain.JobCapabilityFilesRead}, - Capacity: dto.RunCapacityResponse{MaxJobs: 4}, - })) - if !claim.HasJob || claim.Job.JobID != "job-file-snapshot-api-read" { - t.Fatalf("expected file read job claim, got %+v", claim) - } - content := "ServerName=API\nRconPassword=secret\n" - resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{ - RunEndpointID: "run-local", - SessionToken: hello.SessionToken, - JobID: claim.Job.JobID, - LeaseToken: claim.Job.LeaseToken, - Attempt: claim.Job.Attempt, - State: domain.JobStateSucceeded, - Progress: dto.JobProgressBody{Percent: 100, Message: "file read completed"}, - Message: "file read completed", - ExecutionResult: dto.RunJobExecutionResultBody{ - Kind: "file.read", - Version: 9, - SizeBytes: int64(len(content)), - Content: content, - }, - }) - assertStatus(t, resultRecorder, http.StatusOK) - ready := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession) - if ready.State != "ready" || ready.Version != 9 || !strings.Contains(ready.Content, "ServerName=API") || !strings.Contains(ready.Content, "RconPassword=") || strings.Contains(ready.Content, "secret") { - t.Fatalf("expected ready redacted snapshot, got %+v", ready) - } + assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", adminSession), http.StatusNotFound) } func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { @@ -412,7 +277,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { for _, action := range actions.Actions { availability[action.Key] = action.Available } - for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install", "historical-logs"} { + for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install"} { if !availability[key] { t.Fatalf("expected action %q available in %+v", key, actions.Actions) } @@ -464,22 +329,8 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { unsafeDependency := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "bash -c whoami", IdempotencyKey: "api-dependency-unsafe"}, adminSession) assertErrorResponse(t, unsafeDependency, http.StatusBadRequest, errorCodeValidation) - backfillRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession) - assertStatus(t, backfillRecorder, http.StatusAccepted) - backfill := decodeBody[dto.JobResponse](t, backfillRecorder) - if backfill.Capability != domain.JobCapabilityLogsBackfill || backfill.ResultRef != "" || backfill.InputRef == "" { - t.Fatalf("unexpected log backfill job: %+v", backfill) - } - liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession) - foundFileLog := false - for _, stream := range liveLogs.Items { - if stream.Source == domain.LogStreamSourceFile && stream.StreamKey == "latest-log" { - foundFileLog = true - } - } - if !foundFileLog { - t.Fatalf("unexpected live logs: %+v", liveLogs) - } + assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession), http.StatusNotFound) + assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/logs/live", "", adminSession), http.StatusNotFound) runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession) if runReset.Generation != 2 || runReset.SecretRef == "" { @@ -490,7 +341,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { t.Fatalf("unexpected client key reset: %+v", clientReset) } - for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} { + for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall)} { for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} { if strings.Contains(body, forbidden) { t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body) @@ -503,7 +354,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { for _, audit := range audits.Items { auditActions[audit.Action] = true } - for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "logs.backfill", "runtime-key.reset"} { + for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "runtime-key.reset"} { if !auditActions[action] { t.Fatalf("expected audit action %q in %+v", action, audits.Items) } diff --git a/platform/api/routes.md b/platform/api/routes.md index 1494fb0..4944ebd 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -14,10 +14,10 @@ All routes use JSON request and response bodies. Collection routes support `GET` | Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` | | Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` | | Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` | -| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `GET /api/v1/server-instances/{id}/logs/events`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest`, `LogStreamEventResponse` | +| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` | | Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` | -| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` | | File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` | +| SCUM projections and workflows | n/a | `GET /api/v1/server-instances/{id}/scum/players`, `GET .../scum/squads`, `GET .../scum/squad-members`, `GET .../scum/vehicles`, `GET .../scum/flags`, `GET .../scum/positions`, `GET/POST .../scum/operations`, `POST .../scum/operations/{operationId}/approve`, `GET/POST .../scum/workflows`, `GET .../scum/workflow-steps` | `SCUM*Response`, `SCUMOperationRequestBody`, `SCUMWorkflowCreateRequest`, safe operation/workflow summaries | | Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` | | Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` | | Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` | @@ -70,17 +70,15 @@ Server owner membership actions hide and reject platform administrators. Server - `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators. - `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user. -- `GET /api/v1/server-instances/{id}/config`: returns logical server config content, format, key, config version, and update timestamp for an authorized server instance. +Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns reviewable typed diff metadata through AI config-diff approval flows rather than raw config text. -Observability and config read responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens. +Observability responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens. -## Implemented Config Write And File Dispatch Actions +## Implemented File Dispatch Actions -- `POST /api/v1/server-instances/{id}/config/diff`: accepts `ServerConfigDiffPreviewRequest`, validates server access, expected config version, logical config key, bounded proposed content, and returns a platform-computed `ServerConfigDiffPreviewResponse` without creating a run job. -- `POST /api/v1/server-instances/{id}/config/approve`: accepts `ServerConfigWriteApprovalRequest`, revalidates the reviewed diff, rejects stale/no-change/unsafe writes, and queues a scoped `config.write` job using `ServerConfigWriteDispatchResponse`. - `POST /api/v1/file-operations/dispatch`: accepts `FileOperationDispatchRequest`, validates server visibility plus optional plugin permissions, rejects unsafe targets, and queues `files.read` or `files.write` jobs using logical keys and refs. -Config write and file dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents. +File dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents. ## Implemented AI Provider Management Actions @@ -159,12 +157,12 @@ Lifecycle workflow responses include accepted status, action, bounded server ins - `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests. - `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key. - `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation. -- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`. -- `GET /api/v1/server-instances/{id}/logs/events`: streams selected server log metadata and entries as `text/event-stream`; the optional `historyLimit` query replays recent stored entries before live push events. -- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results. +Server-scoped raw log routes (`logs/live`, `logs/events`, and `logs/backfill`) are intentionally not registered as product APIs. Internal log ingest and cursor query remain available for run/platform maintenance flows. Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. +SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and audit-safe summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials. + `POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so. ## Private Run Dependency And Update Routes @@ -197,7 +195,7 @@ Job ack/progress/result/cancel/reconcile calls remain lightweight and independen - `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range. - `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor. -- `GET /api/v1/server-instances/{id}/logs/events`: authorize the browser session for the server, replay bounded recent entries, and push newly ingested log entries over SSE without polling log stream queries. +Server-scoped SSE log streaming is removed from product routes. `POST /api/v1/log-streams/query` remains the bounded cursor contract for internal maintenance/debug reads. Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data. Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup. diff --git a/platform/api/scum_handlers.go b/platform/api/scum_handlers.go new file mode 100644 index 0000000..47ba825 --- /dev/null +++ b/platform/api/scum_handlers.go @@ -0,0 +1,201 @@ +package api + +import ( + "net/http" + "strconv" + + "browser.local/platform/domain" + "browser.local/platform/dto" +) + +func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + items, err := h.core.ListSCUMPlayerLiveStatesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMPlayerLiveStatesFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + items, err := h.core.ListSCUMSquadsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMSquadsFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMSquadMembers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + items, err := h.core.ListSCUMSquadMembersForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMSquadMembersFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMVehicles(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + items, err := h.core.ListSCUMVehiclesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMVehiclesFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMFlags(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + items, err := h.core.ListSCUMFlagsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMFlagsFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMPositions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + items, err := h.core.ListSCUMCurrentPositionsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMCurrentPositionsFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMOperations(w http.ResponseWriter, r *http.Request) { + serverID := r.PathValue("id") + switch r.Method { + case http.MethodGet: + items, err := h.core.ListSCUMOperationsForSession(bearerToken(r), scumOperationFilterFromRequest(r, serverID)) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMOperationsFromDomain(items)) + case http.MethodPost: + request, err := decodeJSON[dto.SCUMOperationRequestBody](r) + if err != nil { + writeDecodeError(w, err) + return + } + operation, err := h.core.RequestSCUMOperationForSession(bearerToken(r), serverID, dto.SCUMOperationRequestBodyToDomain(request)) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.SCUMOperationFromDomain(operation)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +func (h *coreHandlers) serverSCUMOperationApprove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + operation, err := h.core.ApproveSCUMOperationForSession(bearerToken(r), r.PathValue("operationId")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMOperationFromDomain(operation)) +} + +func (h *coreHandlers) serverSCUMWorkflows(w http.ResponseWriter, r *http.Request) { + serverID := r.PathValue("id") + switch r.Method { + case http.MethodGet: + items, err := h.core.ListSCUMWorkflowsForSession(bearerToken(r), scumWorkflowFilterFromRequest(r, serverID)) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMWorkflowsFromDomain(items)) + case http.MethodPost: + request, err := decodeJSON[dto.SCUMWorkflowCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + workflow, err := h.core.CreateSCUMWorkflowForSession(bearerToken(r), serverID, dto.SCUMWorkflowCreateRequestToDomain(request)) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.SCUMWorkflowFromDomain(workflow)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +func (h *coreHandlers) serverSCUMWorkflowSteps(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + items, err := h.core.ListSCUMWorkflowStepsForSession(bearerToken(r), scumWorkflowStepFilterFromRequest(r, r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMWorkflowStepsFromDomain(items)) +} + +func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SCUMProjectionFilter { + query := r.URL.Query() + return domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: query.Get("gamePlayerId"), GamePlayerRecordID: query.Get("gamePlayerRecordId"), UserProfileID: query.Get("userProfileId"), SteamID: query.Get("steamId"), SquadID: query.Get("squadId"), VehicleID: query.Get("vehicleId"), FlagID: query.Get("flagId"), SubjectType: domain.SCUMProjectionSubject(query.Get("subjectType")), QueryKey: query.Get("queryKey"), Freshness: domain.SCUMProjectionFreshness(query.Get("freshness")), Search: query.Get("search"), Limit: boundedQueryLimit(query.Get("limit"), 200)} +} + +func scumOperationFilterFromRequest(r *http.Request, serverID string) domain.SCUMOperationRequestFilter { + query := r.URL.Query() + return domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), PlayerID: query.Get("playerId"), RequesterID: query.Get("requesterId"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)} +} + +func scumWorkflowFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowInstanceFilter { + query := r.URL.Query() + return domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), RequestedBy: query.Get("requestedBy"), Status: domain.SCUMWorkflowStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)} +} + +func scumWorkflowStepFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowStepFilter { + query := r.URL.Query() + return domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, WorkflowID: query.Get("workflowId"), StepKey: query.Get("stepKey"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), Limit: boundedQueryLimit(query.Get("limit"), 200)} +} + +func boundedQueryLimit(raw string, fallback int) int { + if raw == "" { + return fallback + } + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 { + return fallback + } + if parsed > 500 { + return 500 + } + return parsed +} diff --git a/platform/api/scum_handlers_test.go b/platform/api/scum_handlers_test.go new file mode 100644 index 0000000..a420239 --- /dev/null +++ b/platform/api/scum_handlers_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/dto" + "browser.local/platform/repo" + "browser.local/platform/service" +) + +func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testing.T) { + store := repo.NewMemoryStore() + core := service.NewCoreService(store) + if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { + t.Fatalf("create owner: %v", err) + } + plugin := validGamePluginRequest().ToDomain() + plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read") + plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON) + plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}} + plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set", Title: "Set fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-management", TargetKey: "scum-management", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}} + plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000} + if _, err := core.CreateGamePlugin(plugin); err != nil { + t.Fatalf("create plugin: %v", err) + } + endpoint := validRunEndpointRequest().ToDomain() + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON) + endpoint.LastHeartbeatAt = time.Now().UTC() + if _, err := core.CreateRunEndpoint(endpoint); err != nil { + t.Fatalf("create endpoint: %v", err) + } + if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-api", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM API", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning, ConfigVersion: 1}); err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:api-profile", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"gamePlayerId": "steam-api", "displayName": "API Player", "normalBalance": 25, "x": 1, "y": 2, "z": 3}}}); err != nil { + t.Fatalf("seed projection: %v", err) + } + auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner@example.test", Password: "secret-password"}) + if err != nil { + t.Fatalf("login: %v", err) + } + router := NewAuthorizedRouterWithCore(core) + players := getJSONWithAuth[dto.SCUMPlayerLiveStateListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/players", auth.SessionID) + if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 { + t.Fatalf("unexpected SCUM players response: %+v", players) + } + operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.fame.set", PlayerID: "steam-api", Payload: map[string]any{"fame": 12}, Reason: "api typed op", IdempotencyKey: "api-fame-1"}, auth.SessionID) + if operation.Status != string(domain.SCUMWorkflowStepWaiting) || operation.TemplateKey != "player.fame.set" { + t.Fatalf("unexpected SCUM operation response: %+v", operation) + } + operations := getJSONWithAuth[dto.SCUMOperationListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", auth.SessionID) + if operations.Count != 1 || operations.Items[0].ID != operation.ID { + t.Fatalf("unexpected SCUM operation list: %+v", operations) + } + workflow := postJSONWithAuth[dto.SCUMWorkflowResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflows", dto.SCUMWorkflowCreateRequest{TemplateKey: "scum.world-refresh", IdempotencyKey: "api-world-1"}, auth.SessionID) + if workflow.Status != string(domain.SCUMWorkflowQueued) || workflow.TemplateKey != "scum.world-refresh" { + t.Fatalf("unexpected SCUM workflow response: %+v", workflow) + } + steps := getJSONWithAuth[dto.SCUMWorkflowStepListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId="+workflow.ID, auth.SessionID) + if steps.Count == 0 { + t.Fatalf("expected workflow steps: %+v", steps) + } + body, err := json.Marshal([]any{players, operation, operations, workflow, steps}) + if err != nil { + t.Fatalf("marshal responses: %v", err) + } + for _, forbidden := range []string{"#SetFamePoints", "requestText", "SELECT ", "UPDATE ", "SCUM.db", "password", "run token", "hostPath"} { + if strings.Contains(strings.ToUpper(string(body)), strings.ToUpper(forbidden)) { + t.Fatalf("SCUM safe API leaked %q: %s", forbidden, body) + } + } + for _, legacy := range []struct{ method, path string }{ + {http.MethodPost, "/api/v1/server-instances/server-scum-api/rcon/commands"}, + {http.MethodGet, "/api/v1/server-instances/server-scum-api/logs/live"}, + {http.MethodGet, "/api/v1/server-instances/server-scum-api/logs/events"}, + {http.MethodPost, "/api/v1/server-instances/server-scum-api/logs/backfill"}, + {http.MethodGet, "/api/v1/server-instances/server-scum-api/files/read-snapshot?key=scum-server-log"}, + {http.MethodGet, "/api/v1/server-instances/server-scum-api/config"}, + {http.MethodPost, "/api/v1/server-instances/server-scum-api/config/diff"}, + {http.MethodPost, "/api/v1/server-instances/server-scum-api/config/approve"}, + } { + recorder := requestWithAuth(t, router, legacy.method, legacy.path, `{}`, auth.SessionID) + assertStatus(t, recorder, http.StatusNotFound) + } +} + +func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) { + store := repo.NewMemoryStore() + core := service.NewCoreService(store) + if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { + t.Fatal(err) + } + if _, err := core.CreateUser(domain.User{ID: "scum-api-other", DisplayName: "SCUM API Other", Email: "scum-api-other-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { + t.Fatal(err) + } + if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil { + t.Fatal(err) + } + if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil { + t.Fatal(err) + } + if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-authz", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning}); err != nil { + t.Fatal(err) + } + auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-other-authz@example.test", Password: "secret-password"}) + if err != nil { + t.Fatal(err) + } + router := NewAuthorizedRouterWithCore(core) + assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-scum-authz/scum/players", "", auth.SessionID), http.StatusForbidden, errorCodeForbidden) +} + +func TestSCUMAPIsRequirePlatformAdminForDBMutationApproval(t *testing.T) { + store := repo.NewMemoryStore() + core := service.NewCoreService(store) + if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-mutation@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { + t.Fatal(err) + } + plugin := validGamePluginRequest().ToDomain() + plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.read", "server.game-client.maintenance") + plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL) + plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}} + plugin.GameClientBridge.QueryTemplates = []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "scum.player.profile", Title: "Read player profile", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/bridge/queries/scum-player-profile.parameters.schema.json", ResultSchemaRef: "schemas/bridge/queries/scum-player-profile.result.schema.json", MaxRows: 10, TimeoutSeconds: 15}} + plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.attribute.855.set", Title: "Set attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}} + plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000} + if _, err := core.CreateGamePlugin(plugin); err != nil { + t.Fatal(err) + } + endpoint := validRunEndpointRequest().ToDomain() + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL) + if _, err := core.CreateRunEndpoint(endpoint); err != nil { + t.Fatal(err) + } + if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-mutation-authz", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Mutation Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateStopped}); err != nil { + t.Fatal(err) + } + auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner-mutation@example.test", Password: "secret-password"}) + if err != nil { + t.Fatal(err) + } + router := NewAuthorizedRouterWithCore(core) + operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.attribute.855.set", PlayerID: "steam-api", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 12, "safetyWindow": "maintenance-2026-08-10", "backupRef": "backup://scum/1"}, Reason: "api typed db op", IdempotencyKey: "api-855-1"}, auth.SessionID) + assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations/"+operation.ID+"/approve", map[string]string{}, auth.SessionID), http.StatusForbidden, errorCodeForbidden) +} diff --git a/platform/api/source_rcon_handlers.go b/platform/api/source_rcon_handlers.go index 96ac3ab..9812258 100644 --- a/platform/api/source_rcon_handlers.go +++ b/platform/api/source_rcon_handlers.go @@ -6,20 +6,7 @@ import ( "browser.local/platform/dto" ) -// sourceRCONCommands godoc -// @Summary Queue a direct SCUM Source RCON chat or command -// @Description Queues one non-retryable command without persisting the raw command, RCON password, or response body. -// @Tags scum-rcon -// @Accept json -// @Produce json -// @Param id path string true "Server instance ID" -// @Param body body dto.SourceRCONCommandRequestBody true "SCUM Source RCON chat or command request" -// @Success 202 {object} dto.SourceRCONCommandResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/rcon/commands [post] +// sourceRCONCommands is kept as legacy service plumbing but is not registered as a browser product route. func (h *coreHandlers) sourceRCONCommands(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index 38be40a..319d3b8 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -73,11 +73,57 @@ type GameClientBridgeQueryTemplateDeclaration struct { TimeoutSeconds int } +type GameClientBridgeOperationKind string + +const ( + GameClientBridgeOperationKindRCON GameClientBridgeOperationKind = "rcon" + GameClientBridgeOperationKindSQLiteMutation GameClientBridgeOperationKind = "sqlite-mutation" +) + +type GameClientBridgeOperationTemplateDeclaration struct { + Key string + Title string + Permission string + ApprovalLevel GameClientBridgeApprovalLevel + Kind GameClientBridgeOperationKind + TransportKey string + TargetKey string + PayloadSchemaRef string + ResultSchemaRef string + ConfirmationSchemaRef string + TimeoutSeconds int + MaxPayloadBytes int + MaxRowsAffected int + Mutation GameClientBridgeOperationMutationDeclaration + Safety GameClientBridgeOperationSafety +} + +type GameClientBridgeOperationMutationDeclaration struct { + FieldKey string + TableKey string + IdentityKey string + ValueKey string + ConfirmationQueryKey string + AllowedValueType string + MinValue float64 + MaxValue float64 +} + +type GameClientBridgeOperationSafety struct { + RequiresApproval bool + RequiresOfflinePlayer bool + RequiresMaintenanceWindow bool + RequiresBeforeValue bool + RequiresConfirmation bool + BackupRequired bool +} + type GameClientBridgePageContract struct { PageKey string CommandTypes []string SnapshotTypes []string QueryTemplateKeys []string + OperationKeys []string FeatureKeys []string } @@ -106,13 +152,14 @@ type GameClientBridgeCompanionDeclaration struct { } type GameClientBridgeManifest struct { - Commands []GameClientBridgeCommandDeclaration - Snapshots []GameClientBridgeSnapshotDeclaration - QueryTemplates []GameClientBridgeQueryTemplateDeclaration - Retention GameClientBridgeRetention - Pages []GameClientBridgePageContract - Features []GameClientBridgeFeatureDeclaration - Companion GameClientBridgeCompanionDeclaration + Commands []GameClientBridgeCommandDeclaration + Snapshots []GameClientBridgeSnapshotDeclaration + QueryTemplates []GameClientBridgeQueryTemplateDeclaration + OperationTemplates []GameClientBridgeOperationTemplateDeclaration + Retention GameClientBridgeRetention + Pages []GameClientBridgePageContract + Features []GameClientBridgeFeatureDeclaration + Companion GameClientBridgeCompanionDeclaration } type GameClientBridgeResultStatus string @@ -406,12 +453,14 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid } value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) + value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...) for index := range value.Pages { value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes) value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes) value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys) + value.Pages[index].OperationKeys = CopyStringSlice(value.Pages[index].OperationKeys) value.Pages[index].FeatureKeys = CopyStringSlice(value.Pages[index].FeatureKeys) } for index := range value.Features { diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index 8c9dc75..259b286 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -4,13 +4,16 @@ import "testing" func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { manifest := GameClientBridgeManifest{ - QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}}, - Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, + QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}}, + OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}}, + Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}}, } manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy.QueryTemplates[0].Key = "mutated" + manifestCopy.OperationTemplates[0].Key = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" - if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { + manifestCopy.Pages[0].OperationKeys[0] = "mutated" + if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" { t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) } diff --git a/platform/domain/scum_projections.go b/platform/domain/scum_projections.go new file mode 100644 index 0000000..df7eebc --- /dev/null +++ b/platform/domain/scum_projections.go @@ -0,0 +1,185 @@ +package domain + +import "time" + +type SCUMProjectionSubject string + +const ( + SCUMProjectionSubjectPlayer SCUMProjectionSubject = "player" + SCUMProjectionSubjectLiveState SCUMProjectionSubject = "player-live-state" + SCUMProjectionSubjectSquad SCUMProjectionSubject = "squad" + SCUMProjectionSubjectMember SCUMProjectionSubject = "squad-member" + SCUMProjectionSubjectVehicle SCUMProjectionSubject = "vehicle" + SCUMProjectionSubjectFlag SCUMProjectionSubject = "flag" + SCUMProjectionSubjectPosition SCUMProjectionSubject = "position" +) + +type SCUMProjectionFilter struct { + ServerInstanceID string + GamePlayerID string + GamePlayerRecordID string + UserProfileID string + SteamID string + SquadID string + VehicleID string + FlagID string + SubjectType SCUMProjectionSubject + QueryKey string + Freshness SCUMProjectionFreshness + Search string + Limit int +} + +type SCUMPlayerLiveState struct { + ID string + ServerInstanceID string + GamePlayerRecordID string + GamePlayerID string + UserProfileID string + SteamID string + DisplayName string + SquadID string + SquadName string + Online bool + FamePoints float64 + NormalBalance float64 + GoldBalance float64 + LastLoginAt time.Time + LastLogoutAt time.Time + LastSaveTime time.Time + Position SCUMCurrentPosition + UnknownFields map[string]any + Freshness SCUMProjectionFreshnessState + CreatedAt time.Time + UpdatedAt time.Time +} + +type SCUMSquad struct { + ID string + ServerInstanceID string + SquadID string + Name string + LeaderProfileID string + LeaderPlayerID string + MemberCount int + Score float64 + UnknownFields map[string]any + Freshness SCUMProjectionFreshnessState + CreatedAt time.Time + UpdatedAt time.Time +} + +type SCUMSquadMember struct { + ID string + ServerInstanceID string + SquadID string + UserProfileID string + GamePlayerRecordID string + GamePlayerID string + SteamID string + DisplayName string + Rank string + IsLeader bool + JoinedAt time.Time + UnknownFields map[string]any + Freshness SCUMProjectionFreshnessState + CreatedAt time.Time + UpdatedAt time.Time +} + +type SCUMVehicle struct { + ID string + ServerInstanceID string + VehicleID string + EntityID string + ClassName string + Label string + OwnerProfileID string + OwnerPlayerID string + SquadID string + Position SCUMCurrentPosition + UnknownFields map[string]any + Freshness SCUMProjectionFreshnessState + CreatedAt time.Time + UpdatedAt time.Time +} + +type SCUMFlag struct { + ID string + ServerInstanceID string + FlagID string + EntityID string + OwnerProfileID string + OwnerPlayerID string + OwnerSquadID string + OwnerSquadName string + OwnershipConfidence string + Position SCUMCurrentPosition + UnknownFields map[string]any + Freshness SCUMProjectionFreshnessState + CreatedAt time.Time + UpdatedAt time.Time +} + +type SCUMCurrentPosition struct { + ID string + ServerInstanceID string + SubjectType SCUMProjectionSubject + SubjectID string + GamePlayerRecordID string + GamePlayerID string + VehicleID string + EntityID string + MapID string + MapVersion string + X float64 + Y float64 + Z float64 + HasCoordinates bool + LastSaveTime time.Time + Freshness SCUMProjectionFreshnessState + CreatedAt time.Time + UpdatedAt time.Time +} + +func SCUMProjectionStateUnknown() SCUMProjectionFreshnessState { + return SCUMProjectionFreshnessState{Status: SCUMProjectionUnknown} +} + +func CopySCUMPlayerLiveState(value SCUMPlayerLiveState) SCUMPlayerLiveState { + value.Position = CopySCUMCurrentPosition(value.Position) + value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) + value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) + return value +} + +func CopySCUMSquad(value SCUMSquad) SCUMSquad { + value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) + value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) + return value +} + +func CopySCUMSquadMember(value SCUMSquadMember) SCUMSquadMember { + value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) + value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) + return value +} + +func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle { + value.Position = CopySCUMCurrentPosition(value.Position) + value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) + value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) + return value +} + +func CopySCUMFlag(value SCUMFlag) SCUMFlag { + value.Position = CopySCUMCurrentPosition(value.Position) + value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) + value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) + return value +} + +func CopySCUMCurrentPosition(value SCUMCurrentPosition) SCUMCurrentPosition { + value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) + return value +} diff --git a/platform/domain/scum_workflows.go b/platform/domain/scum_workflows.go new file mode 100644 index 0000000..d882614 --- /dev/null +++ b/platform/domain/scum_workflows.go @@ -0,0 +1,282 @@ +package domain + +import "time" + +type SCUMObservationStatus string + +const ( + SCUMObservationAccepted SCUMObservationStatus = "accepted" + SCUMObservationStale SCUMObservationStatus = "stale" + SCUMObservationFailed SCUMObservationStatus = "failed" +) + +type SCUMProjectionFreshness string + +const ( + SCUMProjectionFresh SCUMProjectionFreshness = "fresh" + SCUMProjectionStale SCUMProjectionFreshness = "stale" + SCUMProjectionUnknown SCUMProjectionFreshness = "unknown" +) + +type SCUMWorkflowStatus string + +const ( + SCUMWorkflowDraft SCUMWorkflowStatus = "draft" + SCUMWorkflowQueued SCUMWorkflowStatus = "queued" + SCUMWorkflowRunning SCUMWorkflowStatus = "running" + SCUMWorkflowWaiting SCUMWorkflowStatus = "waiting" + SCUMWorkflowBlocked SCUMWorkflowStatus = "blocked" + SCUMWorkflowConfirming SCUMWorkflowStatus = "confirming" + SCUMWorkflowConfirmed SCUMWorkflowStatus = "confirmed" + SCUMWorkflowFailed SCUMWorkflowStatus = "failed" + SCUMWorkflowUnknown SCUMWorkflowStatus = "unknown" + SCUMWorkflowCancelled SCUMWorkflowStatus = "cancelled" +) + +type SCUMWorkflowStepStatus string + +const ( + SCUMWorkflowStepQueued SCUMWorkflowStepStatus = "queued" + SCUMWorkflowStepRunning SCUMWorkflowStepStatus = "running" + SCUMWorkflowStepWaiting SCUMWorkflowStepStatus = "waiting" + SCUMWorkflowStepBlocked SCUMWorkflowStepStatus = "blocked" + SCUMWorkflowStepConfirming SCUMWorkflowStepStatus = "confirming" + SCUMWorkflowStepConfirmed SCUMWorkflowStepStatus = "confirmed" + SCUMWorkflowStepFailed SCUMWorkflowStepStatus = "failed" + SCUMWorkflowStepUnknown SCUMWorkflowStepStatus = "unknown" + SCUMWorkflowStepCancelled SCUMWorkflowStepStatus = "cancelled" +) + +type SCUMSafeSummary struct { + Title string + Message string + Details map[string]string +} + +type SCUMDataObservation struct { + ID string + ServerInstanceID string + PluginID string + Source string + QueryKey string + SubjectType string + SubjectID string + Sequence uint64 + Checksum string + Status SCUMObservationStatus + ErrorCode string + SafeSummary SCUMSafeSummary + ObservedAt time.Time + ReceivedAt time.Time +} + +type SCUMObservationResult struct { + ServerInstanceID string + PluginID string + Source string + QueryKey string + Sequence uint64 + Checksum string + Status SCUMObservationStatus + ErrorCode string + SafeSummary SCUMSafeSummary + ObservedAt time.Time + ReceivedAt time.Time + Rows []map[string]any +} + +type SCUMProjectionFreshnessState struct { + Status SCUMProjectionFreshness + ObservationID string + Source string + QueryKey string + Sequence uint64 + Checksum string + StaleReason string + ObservedAt time.Time + ReceivedAt time.Time +} + +type SCUMMutationGuard struct { + FieldKey string + Before any + After any + MaxRowsAffected int + SafetyWindow string + BackupRef string + RequiresOfflinePlayer bool + RequiresMaintenance bool + RequiresBackup bool +} + +type SCUMOperationConfirmation struct { + Status string + ObservationID string + ConfirmedFields map[string]any + AffectedRows int + MutationChecksum string + Checksum string + ObservedAt time.Time + SafeSummary SCUMSafeSummary +} + +type SCUMOperationRequest struct { + ID string + ServerInstanceID string + PluginID string + TemplateKey string + PlayerID string + RequesterID string + ApproverID string + ApprovalLevel GameClientBridgeApprovalLevel + Payload map[string]any + Guard SCUMMutationGuard + Confirmation SCUMOperationConfirmation + Status SCUMWorkflowStepStatus + Reason string + IdempotencyKey string + RunJobID string + SafeSummary SCUMSafeSummary + AuditReferences []string + CreatedAt time.Time + ApprovedAt time.Time + CompletedAt time.Time + UpdatedAt time.Time +} + +type SCUMOperationRequestFilter struct { + ServerInstanceID string + PluginID string + TemplateKey string + PlayerID string + RequesterID string + Status SCUMWorkflowStepStatus + IdempotencyKey string + Limit int +} + +type SCUMWorkflowInstanceFilter struct { + ServerInstanceID string + PluginID string + TemplateKey string + RequestedBy string + Status SCUMWorkflowStatus + IdempotencyKey string + Limit int +} + +type SCUMWorkflowStepFilter struct { + WorkflowID string + ServerInstanceID string + StepKey string + Status SCUMWorkflowStepStatus + MutatesState *bool + Limit int +} + +type SCUMWorkflowInstance struct { + ID string + ServerInstanceID string + PluginID string + TemplateKey string + RequestedBy string + IdempotencyKey string + Status SCUMWorkflowStatus + CurrentStepKey string + Input map[string]any + SafeSummary SCUMSafeSummary + BlockerReason string + AuditReferences []string + CreatedAt time.Time + UpdatedAt time.Time + CompletedAt time.Time +} + +type SCUMWorkflowStep struct { + ID string + WorkflowID string + ServerInstanceID string + StepKey string + DependsOn []string + Status SCUMWorkflowStepStatus + OperationKey string + QueryTemplateKey string + Capability string + TargetKey string + JobID string + Attempt int + MaxAttempts int + MutatesState bool + Confirmation SCUMOperationConfirmation + SafeSummary SCUMSafeSummary + BlockerReason string + AuditReferences []string + CreatedAt time.Time + UpdatedAt time.Time + CompletedAt time.Time +} + +func CopySCUMSafeSummary(value SCUMSafeSummary) SCUMSafeSummary { + value.Details = CopyStringMap(value.Details) + return value +} + +func CopySCUMDataObservation(value SCUMDataObservation) SCUMDataObservation { + value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) + return value +} + +func CopySCUMObservationResult(value SCUMObservationResult) SCUMObservationResult { + value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) + value.Rows = CopyGameClientBridgeRows(value.Rows) + return value +} + +func CopyGameClientBridgeRows(values []map[string]any) []map[string]any { + if values == nil { + return nil + } + out := make([]map[string]any, len(values)) + for index, row := range values { + out[index] = CopyGameClientBridgePayload(row) + } + return out +} + +func CopySCUMProjectionFreshnessState(value SCUMProjectionFreshnessState) SCUMProjectionFreshnessState { + return value +} + +func CopySCUMMutationGuard(value SCUMMutationGuard) SCUMMutationGuard { + return value +} + +func CopySCUMOperationConfirmation(value SCUMOperationConfirmation) SCUMOperationConfirmation { + value.ConfirmedFields = CopyGameClientBridgePayload(value.ConfirmedFields) + value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) + return value +} + +func CopySCUMOperationRequest(value SCUMOperationRequest) SCUMOperationRequest { + value.Payload = CopyGameClientBridgePayload(value.Payload) + value.Guard = CopySCUMMutationGuard(value.Guard) + value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation) + value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) + value.AuditReferences = CopyStringSlice(value.AuditReferences) + return value +} + +func CopySCUMWorkflowInstance(value SCUMWorkflowInstance) SCUMWorkflowInstance { + value.Input = CopyGameClientBridgePayload(value.Input) + value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) + value.AuditReferences = CopyStringSlice(value.AuditReferences) + return value +} + +func CopySCUMWorkflowStep(value SCUMWorkflowStep) SCUMWorkflowStep { + value.DependsOn = CopyStringSlice(value.DependsOn) + value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation) + value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) + value.AuditReferences = CopyStringSlice(value.AuditReferences) + return value +} diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 7b03df8..be89354 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -302,11 +302,50 @@ type GameClientBridgeQueryTemplateDeclarationBody struct { TimeoutSeconds int `json:"timeoutSeconds"` } +type GameClientBridgeOperationSafetyBody struct { + RequiresApproval bool `json:"requiresApproval,omitempty"` + RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"` + RequiresMaintenanceWindow bool `json:"requiresMaintenanceWindow,omitempty"` + RequiresBeforeValue bool `json:"requiresBeforeValue,omitempty"` + RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` + BackupRequired bool `json:"backupRequired,omitempty"` +} + +type GameClientBridgeOperationMutationDeclarationBody struct { + FieldKey string `json:"fieldKey"` + TableKey string `json:"tableKey"` + IdentityKey string `json:"identityKey"` + ValueKey string `json:"valueKey"` + ConfirmationQueryKey string `json:"confirmationQueryKey"` + AllowedValueType string `json:"allowedValueType"` + MinValue float64 `json:"minValue,omitempty"` + MaxValue float64 `json:"maxValue,omitempty"` +} + +type GameClientBridgeOperationTemplateDeclarationBody struct { + Key string `json:"key"` + Title string `json:"title"` + Permission string `json:"permission"` + ApprovalLevel string `json:"approvalLevel"` + Kind string `json:"kind"` + TransportKey string `json:"transportKey"` + TargetKey string `json:"targetKey"` + PayloadSchemaRef string `json:"payloadSchemaRef"` + ResultSchemaRef string `json:"resultSchemaRef,omitempty"` + ConfirmationSchemaRef string `json:"confirmationSchemaRef,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds"` + MaxPayloadBytes int `json:"maxPayloadBytes"` + MaxRowsAffected int `json:"maxRowsAffected,omitempty"` + Mutation GameClientBridgeOperationMutationDeclarationBody `json:"mutation,omitempty"` + Safety GameClientBridgeOperationSafetyBody `json:"safety,omitempty"` +} + type GameClientBridgePageContractBody struct { PageKey string `json:"pageKey"` CommandTypes []string `json:"commandTypes,omitempty"` SnapshotTypes []string `json:"snapshotTypes,omitempty"` QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"` + OperationKeys []string `json:"operationKeys,omitempty"` FeatureKeys []string `json:"featureKeys,omitempty"` } @@ -335,14 +374,15 @@ type GameClientBridgeCompanionDeclarationBody struct { } type GameClientBridgeManifestBody struct { - Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` - Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` - QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` - CommandRetentionSeconds int `json:"commandRetentionSeconds"` - MaxCommands int `json:"maxCommands"` - Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` - Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` - Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` + Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` + Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` + QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` + OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"` + CommandRetentionSeconds int `json:"commandRetentionSeconds"` + MaxCommands int `json:"maxCommands"` + Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` + Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` + Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` } type GameMapTrajectoryDeclarationBody struct { MapID string `json:"mapId"` @@ -1167,9 +1207,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif for index, template := range body.QueryTemplates { queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds} } + operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates)) + for index, template := range body.OperationTemplates { + operationTemplates[index] = domain.GameClientBridgeOperationTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(template.ApprovalLevel), Kind: domain.GameClientBridgeOperationKind(template.Kind), TransportKey: template.TransportKey, TargetKey: template.TargetKey, PayloadSchemaRef: template.PayloadSchemaRef, ResultSchemaRef: template.ResultSchemaRef, ConfirmationSchemaRef: template.ConfirmationSchemaRef, TimeoutSeconds: template.TimeoutSeconds, MaxPayloadBytes: template.MaxPayloadBytes, MaxRowsAffected: template.MaxRowsAffected, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: template.Mutation.FieldKey, TableKey: template.Mutation.TableKey, IdentityKey: template.Mutation.IdentityKey, ValueKey: template.Mutation.ValueKey, ConfirmationQueryKey: template.Mutation.ConfirmationQueryKey, AllowedValueType: template.Mutation.AllowedValueType, MinValue: template.Mutation.MinValue, MaxValue: template.Mutation.MaxValue}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: template.Safety.RequiresApproval, RequiresOfflinePlayer: template.Safety.RequiresOfflinePlayer, RequiresMaintenanceWindow: template.Safety.RequiresMaintenanceWindow, RequiresBeforeValue: template.Safety.RequiresBeforeValue, RequiresConfirmation: template.Safety.RequiresConfirmation, BackupRequired: template.Safety.BackupRequired}} + } pages := make([]domain.GameClientBridgePageContract, len(body.Pages)) for index, page := range body.Pages { - pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)} + pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys), OperationKeys: domain.CopyStringSlice(page.OperationKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)} } features := make([]domain.GameClientBridgeFeatureDeclaration, len(body.Features)) for index, feature := range body.Features { @@ -1179,7 +1223,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif if body.Companion != nil { companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds} } - return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} + return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} } func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration { @@ -1595,9 +1639,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G for index, template := range value.QueryTemplates { queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds} } + operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates)) + for index, template := range value.OperationTemplates { + operationTemplates[index] = GameClientBridgeOperationTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, ApprovalLevel: string(template.ApprovalLevel), Kind: string(template.Kind), TransportKey: template.TransportKey, TargetKey: template.TargetKey, PayloadSchemaRef: template.PayloadSchemaRef, ResultSchemaRef: template.ResultSchemaRef, ConfirmationSchemaRef: template.ConfirmationSchemaRef, TimeoutSeconds: template.TimeoutSeconds, MaxPayloadBytes: template.MaxPayloadBytes, MaxRowsAffected: template.MaxRowsAffected, Mutation: GameClientBridgeOperationMutationDeclarationBody{FieldKey: template.Mutation.FieldKey, TableKey: template.Mutation.TableKey, IdentityKey: template.Mutation.IdentityKey, ValueKey: template.Mutation.ValueKey, ConfirmationQueryKey: template.Mutation.ConfirmationQueryKey, AllowedValueType: template.Mutation.AllowedValueType, MinValue: template.Mutation.MinValue, MaxValue: template.Mutation.MaxValue}, Safety: GameClientBridgeOperationSafetyBody{RequiresApproval: template.Safety.RequiresApproval, RequiresOfflinePlayer: template.Safety.RequiresOfflinePlayer, RequiresMaintenanceWindow: template.Safety.RequiresMaintenanceWindow, RequiresBeforeValue: template.Safety.RequiresBeforeValue, RequiresConfirmation: template.Safety.RequiresConfirmation, BackupRequired: template.Safety.BackupRequired}} + } pages := make([]GameClientBridgePageContractBody, len(value.Pages)) for index, page := range value.Pages { - pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys, FeatureKeys: page.FeatureKeys} + pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys, OperationKeys: page.OperationKeys, FeatureKeys: page.FeatureKeys} } features := make([]GameClientBridgeFeatureDeclarationBody, len(value.Features)) for index, feature := range value.Features { @@ -1607,7 +1655,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G if value.Companion.ProfileKey != "" { companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds} } - return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} + return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} } func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index 628bc1d..f7a64b0 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -3,6 +3,7 @@ package dto import ( "encoding/json" "reflect" + "strings" "testing" "browser.local/platform/domain" @@ -177,3 +178,40 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { } } } + +func TestGameClientBridgeOperationTemplateDeclarationRoundTripIsSafe(t *testing.T) { + body := GameClientBridgeManifestBody{ + OperationTemplates: []GameClientBridgeOperationTemplateDeclarationBody{{ + Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: "platform-admin", Kind: "sqlite-mutation", TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", + PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, + Safety: GameClientBridgeOperationSafetyBody{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}, + }}, + CommandRetentionSeconds: 86400, + MaxCommands: 1000, + Pages: []GameClientBridgePageContractBody{{PageKey: "players", OperationKeys: []string{"player.attribute.855.set"}}}, + } + + domainManifest := body.ToDomain() + if len(domainManifest.OperationTemplates) != 1 || domainManifest.OperationTemplates[0].Kind != "sqlite-mutation" || domainManifest.OperationTemplates[0].MaxRowsAffected != 1 || !domainManifest.OperationTemplates[0].Safety.RequiresBeforeValue || domainManifest.Pages[0].OperationKeys[0] != "player.attribute.855.set" { + t.Fatalf("operation template conversion lost declaration fields: %#v", domainManifest) + } + domainManifest.Pages[0].OperationKeys[0] = "mutated" + if body.Pages[0].OperationKeys[0] != "player.attribute.855.set" { + t.Fatal("operation template page keys alias request DTO data") + } + domainManifest.Pages[0].OperationKeys[0] = "player.attribute.855.set" + + response := gameClientBridgeManifestFromDomain(domainManifest) + response.Pages[0].OperationKeys[0] = "mutated" + if domainManifest.Pages[0].OperationKeys[0] != "player.attribute.855.set" { + t.Fatal("operation template page keys alias domain data") + } + + encoded, err := json.Marshal(response.OperationTemplates[0]) + if err != nil { + t.Fatalf("marshal safe operation template projection: %v", err) + } + if strings.Contains(strings.ToLower(string(encoded)), "sqltext") || strings.Contains(strings.ToLower(string(encoded)), "dsn") || strings.Contains(strings.ToLower(string(encoded)), "hostpath") || strings.Contains(strings.ToLower(string(encoded)), "socket") || strings.Contains(strings.ToLower(string(encoded)), "credential") { + t.Fatalf("operation template projection leaked unsafe material: %s", encoded) + } +} diff --git a/platform/dto/scum_projections.go b/platform/dto/scum_projections.go new file mode 100644 index 0000000..56feff6 --- /dev/null +++ b/platform/dto/scum_projections.go @@ -0,0 +1,81 @@ +package dto + +import "browser.local/platform/domain" + +type SCUMPlayerLiveStateListResponse struct { + Items []domain.SCUMPlayerLiveState `json:"items"` + Count int `json:"count"` +} + +type SCUMSquadListResponse struct { + Items []domain.SCUMSquad `json:"items"` + Count int `json:"count"` +} + +type SCUMSquadMemberListResponse struct { + Items []domain.SCUMSquadMember `json:"items"` + Count int `json:"count"` +} + +type SCUMVehicleListResponse struct { + Items []domain.SCUMVehicle `json:"items"` + Count int `json:"count"` +} + +type SCUMFlagListResponse struct { + Items []domain.SCUMFlag `json:"items"` + Count int `json:"count"` +} + +type SCUMCurrentPositionListResponse struct { + Items []domain.SCUMCurrentPosition `json:"items"` + Count int `json:"count"` +} + +func SCUMPlayerLiveStatesFromDomain(values []domain.SCUMPlayerLiveState) SCUMPlayerLiveStateListResponse { + out := make([]domain.SCUMPlayerLiveState, len(values)) + for index, value := range values { + out[index] = domain.CopySCUMPlayerLiveState(value) + } + return SCUMPlayerLiveStateListResponse{Items: out, Count: len(out)} +} + +func SCUMSquadsFromDomain(values []domain.SCUMSquad) SCUMSquadListResponse { + out := make([]domain.SCUMSquad, len(values)) + for index, value := range values { + out[index] = domain.CopySCUMSquad(value) + } + return SCUMSquadListResponse{Items: out, Count: len(out)} +} + +func SCUMSquadMembersFromDomain(values []domain.SCUMSquadMember) SCUMSquadMemberListResponse { + out := make([]domain.SCUMSquadMember, len(values)) + for index, value := range values { + out[index] = domain.CopySCUMSquadMember(value) + } + return SCUMSquadMemberListResponse{Items: out, Count: len(out)} +} + +func SCUMVehiclesFromDomain(values []domain.SCUMVehicle) SCUMVehicleListResponse { + out := make([]domain.SCUMVehicle, len(values)) + for index, value := range values { + out[index] = domain.CopySCUMVehicle(value) + } + return SCUMVehicleListResponse{Items: out, Count: len(out)} +} + +func SCUMFlagsFromDomain(values []domain.SCUMFlag) SCUMFlagListResponse { + out := make([]domain.SCUMFlag, len(values)) + for index, value := range values { + out[index] = domain.CopySCUMFlag(value) + } + return SCUMFlagListResponse{Items: out, Count: len(out)} +} + +func SCUMCurrentPositionsFromDomain(values []domain.SCUMCurrentPosition) SCUMCurrentPositionListResponse { + out := make([]domain.SCUMCurrentPosition, len(values)) + for index, value := range values { + out[index] = domain.CopySCUMCurrentPosition(value) + } + return SCUMCurrentPositionListResponse{Items: out, Count: len(out)} +} diff --git a/platform/dto/scum_workflows.go b/platform/dto/scum_workflows.go new file mode 100644 index 0000000..7cac527 --- /dev/null +++ b/platform/dto/scum_workflows.go @@ -0,0 +1,243 @@ +package dto + +import ( + "time" + + "browser.local/platform/domain" +) + +type SCUMSafeSummaryBody struct { + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + Details map[string]string `json:"details,omitempty"` +} + +type SCUMDataObservationResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId"` + Source string `json:"source"` + QueryKey string `json:"queryKey,omitempty"` + SubjectType string `json:"subjectType,omitempty"` + SubjectID string `json:"subjectId,omitempty"` + Sequence uint64 `json:"sequence"` + Checksum string `json:"checksum,omitempty"` + Status string `json:"status"` + ErrorCode string `json:"errorCode,omitempty"` + SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` + ObservedAt time.Time `json:"observedAt"` + ReceivedAt time.Time `json:"receivedAt"` +} + +type SCUMProjectionFreshnessBody struct { + Status string `json:"status"` + ObservationID string `json:"observationId,omitempty"` + Source string `json:"source,omitempty"` + QueryKey string `json:"queryKey,omitempty"` + Sequence uint64 `json:"sequence,omitempty"` + Checksum string `json:"checksum,omitempty"` + StaleReason string `json:"staleReason,omitempty"` + ObservedAt time.Time `json:"observedAt,omitempty"` + ReceivedAt time.Time `json:"receivedAt,omitempty"` +} + +type SCUMMutationGuardBody struct { + FieldKey string `json:"fieldKey,omitempty"` + Before any `json:"before,omitempty"` + After any `json:"after,omitempty"` + MaxRowsAffected int `json:"maxRowsAffected,omitempty"` + SafetyWindow string `json:"safetyWindow,omitempty"` + BackupRef string `json:"backupRef,omitempty"` + RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"` + RequiresMaintenance bool `json:"requiresMaintenance,omitempty"` + RequiresBackup bool `json:"requiresBackup,omitempty"` +} + +type SCUMOperationConfirmationBody struct { + Status string `json:"status,omitempty"` + ObservationID string `json:"observationId,omitempty"` + ConfirmedFields map[string]any `json:"confirmedFields,omitempty"` + AffectedRows int `json:"affectedRows,omitempty"` + MutationChecksum string `json:"mutationChecksum,omitempty"` + Checksum string `json:"checksum,omitempty"` + ObservedAt time.Time `json:"observedAt,omitempty"` + SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` +} + +type SCUMOperationRequestBody struct { + TemplateKey string `json:"templateKey"` + PlayerID string `json:"playerId,omitempty"` + Payload map[string]any `json:"payload,omitempty"` + Guard SCUMMutationGuardBody `json:"guard,omitempty"` + Reason string `json:"reason"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type SCUMWorkflowCreateRequest struct { + TemplateKey string `json:"templateKey"` + IdempotencyKey string `json:"idempotencyKey"` + Input map[string]any `json:"input,omitempty"` +} + +type SCUMOperationResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId"` + TemplateKey string `json:"templateKey"` + PlayerID string `json:"playerId,omitempty"` + RequesterID string `json:"requesterId,omitempty"` + ApproverID string `json:"approverId,omitempty"` + ApprovalLevel string `json:"approvalLevel"` + Payload map[string]any `json:"payload,omitempty"` + Guard SCUMMutationGuardBody `json:"guard,omitempty"` + Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + RunJobID string `json:"runJobId,omitempty"` + SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` + AuditReferences []string `json:"auditReferences,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ApprovedAt time.Time `json:"approvedAt,omitempty"` + CompletedAt time.Time `json:"completedAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type SCUMOperationListResponse struct { + Items []SCUMOperationResponse `json:"items"` + Count int `json:"count"` +} + +type SCUMWorkflowResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId"` + TemplateKey string `json:"templateKey"` + RequestedBy string `json:"requestedBy,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + Status string `json:"status"` + CurrentStepKey string `json:"currentStepKey,omitempty"` + Input map[string]any `json:"input,omitempty"` + SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` + BlockerReason string `json:"blockerReason,omitempty"` + AuditReferences []string `json:"auditReferences,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CompletedAt time.Time `json:"completedAt,omitempty"` +} + +type SCUMWorkflowListResponse struct { + Items []SCUMWorkflowResponse `json:"items"` + Count int `json:"count"` +} + +type SCUMWorkflowStepResponse struct { + ID string `json:"id"` + WorkflowID string `json:"workflowId"` + ServerInstanceID string `json:"serverInstanceId"` + StepKey string `json:"stepKey"` + DependsOn []string `json:"dependsOn,omitempty"` + Status string `json:"status"` + OperationKey string `json:"operationKey,omitempty"` + QueryTemplateKey string `json:"queryTemplateKey,omitempty"` + Capability string `json:"capability,omitempty"` + TargetKey string `json:"targetKey,omitempty"` + JobID string `json:"jobId,omitempty"` + Attempt int `json:"attempt,omitempty"` + MaxAttempts int `json:"maxAttempts,omitempty"` + MutatesState bool `json:"mutatesState,omitempty"` + Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"` + SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` + BlockerReason string `json:"blockerReason,omitempty"` + AuditReferences []string `json:"auditReferences,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CompletedAt time.Time `json:"completedAt,omitempty"` +} + +type SCUMWorkflowStepListResponse struct { + Items []SCUMWorkflowStepResponse `json:"items"` + Count int `json:"count"` +} + +func SCUMSafeSummaryFromDomain(value domain.SCUMSafeSummary) SCUMSafeSummaryBody { + value = domain.CopySCUMSafeSummary(value) + return SCUMSafeSummaryBody{Title: value.Title, Message: value.Message, Details: value.Details} +} + +func scumSafeSummaryToDomain(value SCUMSafeSummaryBody) domain.SCUMSafeSummary { + return domain.SCUMSafeSummary{Title: value.Title, Message: value.Message, Details: domain.CopyStringMap(value.Details)} +} + +func SCUMDataObservationFromDomain(value domain.SCUMDataObservation) SCUMDataObservationResponse { + value = domain.CopySCUMDataObservation(value) + return SCUMDataObservationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Source: value.Source, QueryKey: value.QueryKey, SubjectType: value.SubjectType, SubjectID: value.SubjectID, Sequence: value.Sequence, Checksum: value.Checksum, Status: string(value.Status), ErrorCode: value.ErrorCode, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt} +} + +func SCUMProjectionFreshnessFromDomain(value domain.SCUMProjectionFreshnessState) SCUMProjectionFreshnessBody { + value = domain.CopySCUMProjectionFreshnessState(value) + return SCUMProjectionFreshnessBody{Status: string(value.Status), ObservationID: value.ObservationID, Source: value.Source, QueryKey: value.QueryKey, Sequence: value.Sequence, Checksum: value.Checksum, StaleReason: value.StaleReason, ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt} +} + +func SCUMOperationRequestBodyToDomain(request SCUMOperationRequestBody) domain.SCUMOperationRequest { + return domain.SCUMOperationRequest{TemplateKey: request.TemplateKey, PlayerID: request.PlayerID, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: scumMutationGuardToDomain(request.Guard), Reason: request.Reason, IdempotencyKey: request.IdempotencyKey} +} + +func SCUMWorkflowCreateRequestToDomain(request SCUMWorkflowCreateRequest) domain.SCUMWorkflowInstance { + return domain.SCUMWorkflowInstance{TemplateKey: request.TemplateKey, IdempotencyKey: request.IdempotencyKey, Input: domain.CopyGameClientBridgePayload(request.Input)} +} + +func SCUMOperationFromDomain(value domain.SCUMOperationRequest) SCUMOperationResponse { + value = domain.CopySCUMOperationRequest(value) + return SCUMOperationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, PlayerID: value.PlayerID, RequesterID: value.RequesterID, ApproverID: value.ApproverID, ApprovalLevel: string(value.ApprovalLevel), Payload: value.Payload, Guard: scumMutationGuardFromDomain(value.Guard), Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), Status: string(value.Status), Reason: value.Reason, RunJobID: value.RunJobID, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, ApprovedAt: value.ApprovedAt, CompletedAt: value.CompletedAt, UpdatedAt: value.UpdatedAt} +} + +func SCUMOperationsFromDomain(values []domain.SCUMOperationRequest) SCUMOperationListResponse { + items := make([]SCUMOperationResponse, len(values)) + for index, value := range values { + items[index] = SCUMOperationFromDomain(value) + } + return SCUMOperationListResponse{Items: items, Count: len(items)} +} + +func SCUMWorkflowFromDomain(value domain.SCUMWorkflowInstance) SCUMWorkflowResponse { + value = domain.CopySCUMWorkflowInstance(value) + return SCUMWorkflowResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, RequestedBy: value.RequestedBy, IdempotencyKey: value.IdempotencyKey, Status: string(value.Status), CurrentStepKey: value.CurrentStepKey, Input: value.Input, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt} +} + +func SCUMWorkflowsFromDomain(values []domain.SCUMWorkflowInstance) SCUMWorkflowListResponse { + items := make([]SCUMWorkflowResponse, len(values)) + for index, value := range values { + items[index] = SCUMWorkflowFromDomain(value) + } + return SCUMWorkflowListResponse{Items: items, Count: len(items)} +} + +func SCUMWorkflowStepFromDomain(value domain.SCUMWorkflowStep) SCUMWorkflowStepResponse { + value = domain.CopySCUMWorkflowStep(value) + return SCUMWorkflowStepResponse{ID: value.ID, WorkflowID: value.WorkflowID, ServerInstanceID: value.ServerInstanceID, StepKey: value.StepKey, DependsOn: value.DependsOn, Status: string(value.Status), OperationKey: value.OperationKey, QueryTemplateKey: value.QueryTemplateKey, Capability: value.Capability, TargetKey: value.TargetKey, JobID: value.JobID, Attempt: value.Attempt, MaxAttempts: value.MaxAttempts, MutatesState: value.MutatesState, Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt} +} + +func SCUMWorkflowStepsFromDomain(values []domain.SCUMWorkflowStep) SCUMWorkflowStepListResponse { + items := make([]SCUMWorkflowStepResponse, len(values)) + for index, value := range values { + items[index] = SCUMWorkflowStepFromDomain(value) + } + return SCUMWorkflowStepListResponse{Items: items, Count: len(items)} +} + +func scumMutationGuardFromDomain(value domain.SCUMMutationGuard) SCUMMutationGuardBody { + return SCUMMutationGuardBody{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup} +} + +func scumMutationGuardToDomain(value SCUMMutationGuardBody) domain.SCUMMutationGuard { + return domain.SCUMMutationGuard{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup} +} + +func scumOperationConfirmationFromDomain(value domain.SCUMOperationConfirmation) SCUMOperationConfirmationBody { + value = domain.CopySCUMOperationConfirmation(value) + return SCUMOperationConfirmationBody{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: value.ConfirmedFields, AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary)} +} + +func scumOperationConfirmationToDomain(value SCUMOperationConfirmationBody) domain.SCUMOperationConfirmation { + return domain.SCUMOperationConfirmation{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: domain.CopyGameClientBridgePayload(value.ConfirmedFields), AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: scumSafeSummaryToDomain(value.SafeSummary)} +} diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index 2370c1f..a1fbdb8 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -67,11 +67,12 @@ Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the ac ## Log Ingest +SCUM-specific read/write execution requirements are defined in `platform/protocol/scum-run-integration.md`. The implementation still belongs to the independent run repository and uses the generic signed job/log channels described here. + Implemented HTTP JSON routes: - `POST /api/v1/run/logs/batches` - `POST /api/v1/log-streams/query` -- `GET /api/v1/server-instances/{id}/logs/events` Named log DTOs: diff --git a/platform/protocol/scum-run-integration.md b/platform/protocol/scum-run-integration.md new file mode 100644 index 0000000..07fa0fb --- /dev/null +++ b/platform/protocol/scum-run-integration.md @@ -0,0 +1,67 @@ +# SCUM Run Integration Contract + +This repository defines the platform/plugin side of SCUM real-data operations. The executable machine-side implementation belongs in the independent `git@git.npc0.com:admin343/run.git` repository and must not be added here. + +## Ownership Boundary + +- Platform owns server instances, authorization, audit, local projections, typed operation/workflow records, idempotency, approval state, and safe browser APIs. +- The SCUM plugin owns query template keys, operation template keys, result schemas, safety rules, confirmation schemas, and lifecycle action assets. +- Run owns local machine execution beside the current SCUM service: locating the declared logical SCUM.db/log/RCON targets from its scoped package, executing bounded jobs, and returning typed results through existing signed job channels. + +Run must never send host paths, DSNs, sockets, credentials, raw SQL, raw RCON text, or protected request bodies to browser/product APIs. Platform persists only safe job metadata, projection rows, checksums, confirmation summaries, and audit references. + +## Read Observation Jobs + +Run must implement plugin-declared SQLite read templates for the current server binding and return rows matching the referenced schema files under `plugins/examples/scum-server-plugin/schemas/bridge/queries/`. + +Required template keys: + +| Key | Required behavior | +| --- | --- | +| `scum.player.profile` | Read player identity, profile ID, optional Steam/user ID, character/prisoner fields, economy balances, squad summary, and current coordinates where available. | +| `scum.squads` | Read squad IDs, names, leader/profile references, and bounded member counts. | +| `scum.squad-members` | Read roster membership, ranks, player/profile references, and unknown fields without fabricating missing identities. | +| `scum.vehicles` | Read vehicle/entity rows and coordinates; unknown class/name mappings remain unknown. | +| `scum.flags` | Read base flag/entity ownership, squad/player confidence, and coordinates where available. | +| `scum.positions` | Read current player, vehicle, and flag coordinate projections. | + +Each successful result must include the server binding, template key, observed time, monotonically comparable sequence, row count within manifest bounds, and `sha256:` checksum. Failures must return safe error codes such as missing database, locked database, schema mismatch, timeout, or row-bound exceeded; platform will mark affected projections stale while keeping last-known-good records. + +Login/logout evidence comes from plugin-declared log sources. A login line can create/update a local player/session projection; `last_save_time` is only freshness evidence and must not be treated as online-state proof by itself. + +## Controlled Write Jobs + +Run must execute only typed operations declared by the SCUM plugin manifest. + +| Operation key | Transport | Required behavior | +| --- | --- | --- | +| `player.fame.set` | RCON | Use the declared command template for fame and confirm through follow-up readback. | +| `player.currency.normal.set` | RCON | Use the declared command template for normal currency and confirm through follow-up readback. | +| `player.currency.gold.set` | RCON | Use the declared command template for gold and confirm through follow-up readback. | +| `player.notify` | RCON/declared notification command | Deliver bounded player notification text and report unknown if delivery cannot be proven. | +| `reward.deliver` | Declared reward transport | Deliver catalogued reward/notification only once per idempotency key and confirmation state. | +| `player.attribute.855.set` | SQLite mutation | Execute the declared DB-only mutation with before-value guard, max affected rows = 1, maintenance/offline evidence, backup/snapshot reference, and confirmation query. | + +RCON-supported fame/currency writes must not be converted to DB mutations. DB-only mutations must fail safely when the current value differs from the approved `before` value, the affected row bound is exceeded, backup evidence is missing, or the player safety state is online/unknown. + +## Result And Confirmation Contract + +Run job results for SCUM reads, RCON writes, and SQLite mutations must return: + +- `kind` identifying the declared result type. +- `checksum` as `sha256:<64 hex chars>`. +- Bounded JSON content matching the plugin result/confirmation schema. +- `affectedRows` for mutations and zero/one row confirmation details where applicable. +- A safe audit summary that excludes raw SQL, raw RCON text, SCUM.db paths, host paths, tokens, sockets, and credentials. + +If execution may have happened but confirmation is missing, run should report an unknown/pending-confirmation state rather than success. Platform will read back before retrying so gifts, currency, fame, and DB fields are not duplicated or overwritten. + +## External Run Tasks + +The independent run repository needs implementation work for: + +1. Resolve package-scoped logical SCUM.db and log targets from the generated run plan without exposing resolved host paths to Platform Web. +2. Execute the six declared SQLite read templates with row/time bounds and schema-compatible JSON rows. +3. Execute typed RCON operation templates for fame, currency, notification, and reward delivery without accepting arbitrary browser command text. +4. Execute `player.attribute.855.set` through a guarded SQLite mutation with backup, maintenance/offline checks, before-value match, affected-row bound, and confirmation read. +5. Report observation failures and write unknown states with safe codes and checksums so platform projections and workflows can reconcile deterministically. diff --git a/platform/protocol/server-lifecycle.md b/platform/protocol/server-lifecycle.md index e3d6929..f565d94 100644 --- a/platform/protocol/server-lifecycle.md +++ b/platform/protocol/server-lifecycle.md @@ -51,15 +51,13 @@ A server instance is created from one installed game management plugin and is la - `POST /api/v1/server-instances/workflows/create` validates an installed plugin, server name, idempotency key, and plugin-declared create inputs when provided. It creates the instance without requiring a deployment target, run endpoint, or runtime profile. Generated Run packages carry the autonomous lifecycle plan that Run consumes on startup; registration confirms binding/auth and does not enqueue bootstrap lifecycle jobs. - `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job. - `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job. -- `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata. -- `POST /api/v1/server-instances/{id}/config/diff` validates an authorized proposed config write against the current config version and returns a bounded platform diff without queuing work. -- `POST /api/v1/server-instances/{id}/config/approve` revalidates an explicitly reviewed config diff and queues a scoped `config.write` job using a logical config key and input ref. +- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses reviewable AI config-diff approvals and typed dispatch metadata without returning raw config text to plugin pages. - `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks. - `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user. Workflow route responses include the accepted action, bounded server instance metadata, and bounded job metadata. They do not expose run session tokens, host paths, raw credentials, direct sockets, AI provider keys, or plugin action file contents. -Config read and server metrics responses are also bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys. +Server metrics responses are bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys. Config write approval and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads. diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index 60a28cb..d260152 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -53,6 +53,16 @@ type StoreSnapshot struct { GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"` GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"` GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"` + SCUMDataObservations []domain.SCUMDataObservation `json:"scumDataObservations"` + SCUMPlayerLiveStates []domain.SCUMPlayerLiveState `json:"scumPlayerLiveStates"` + SCUMSquads []domain.SCUMSquad `json:"scumSquads"` + SCUMSquadMembers []domain.SCUMSquadMember `json:"scumSquadMembers"` + SCUMVehicles []domain.SCUMVehicle `json:"scumVehicles"` + SCUMFlags []domain.SCUMFlag `json:"scumFlags"` + SCUMCurrentPositions []domain.SCUMCurrentPosition `json:"scumCurrentPositions"` + SCUMOperationRequests []domain.SCUMOperationRequest `json:"scumOperationRequests"` + SCUMWorkflowInstances []domain.SCUMWorkflowInstance `json:"scumWorkflowInstances"` + SCUMWorkflowSteps []domain.SCUMWorkflowStep `json:"scumWorkflowSteps"` } type FileStore struct { @@ -234,6 +244,36 @@ func (store *FileStore) GameGiftRevisions() GameGiftRevisionRepository { func (store *FileStore) GameGiftGrants() GameGiftGrantRepository { return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist} } +func (store *FileStore) SCUMDataObservations() SCUMDataObservationRepository { + return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist} +} +func (store *FileStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { + return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist} +} +func (store *FileStore) SCUMSquads() SCUMSquadRepository { + return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist} +} +func (store *FileStore) SCUMSquadMembers() SCUMSquadMemberRepository { + return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist} +} +func (store *FileStore) SCUMVehicles() SCUMVehicleRepository { + return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist} +} +func (store *FileStore) SCUMFlags() SCUMFlagRepository { + return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist} +} +func (store *FileStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { + return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist} +} +func (store *FileStore) SCUMOperationRequests() SCUMOperationRequestRepository { + return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist} +} +func (store *FileStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { + return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist} +} +func (store *FileStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { + return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist} +} func (store *FileStore) load() error { data, err := os.ReadFile(store.path) @@ -307,7 +347,7 @@ func (store *FileStore) snapshot() StoreSnapshot { GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), - GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), + GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps), } } @@ -352,6 +392,16 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs) loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions) loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants) + loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations) + loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates) + loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads) + loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers) + loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles) + loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags) + loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions) + loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests) + loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances) + loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps) } type mutableRepository[T any, F any] interface { diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index b4a33c1..07dd920 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -204,6 +204,36 @@ func (store *MySQLStore) GameGiftRevisions() GameGiftRevisionRepository { func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository { return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist} } +func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository { + return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist} +} +func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { + return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist} +} +func (store *MySQLStore) SCUMSquads() SCUMSquadRepository { + return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist} +} +func (store *MySQLStore) SCUMSquadMembers() SCUMSquadMemberRepository { + return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist} +} +func (store *MySQLStore) SCUMVehicles() SCUMVehicleRepository { + return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist} +} +func (store *MySQLStore) SCUMFlags() SCUMFlagRepository { + return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist} +} +func (store *MySQLStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { + return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist} +} +func (store *MySQLStore) SCUMOperationRequests() SCUMOperationRequestRepository { + return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist} +} +func (store *MySQLStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { + return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist} +} +func (store *MySQLStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { + return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist} +} func (store *MySQLStore) initialize() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -294,7 +324,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot { GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), - GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), + GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps), } } @@ -339,4 +369,14 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs) loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions) loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants) + loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations) + loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates) + loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads) + loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers) + loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles) + loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags) + loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions) + loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests) + loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances) + loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps) } diff --git a/platform/repo/resources.go b/platform/repo/resources.go index 3bec628..f8fbfa5 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -295,6 +295,66 @@ type GameGiftGrantRepository interface { List(domain.GameGiftGrantFilter) ([]domain.GameGiftGrant, error) Update(domain.GameGiftGrant) error } +type SCUMDataObservationRepository interface { + Create(domain.SCUMDataObservation) error + Get(string) (domain.SCUMDataObservation, error) + List(domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error) + Update(domain.SCUMDataObservation) error +} +type SCUMPlayerLiveStateRepository interface { + Create(domain.SCUMPlayerLiveState) error + Get(string) (domain.SCUMPlayerLiveState, error) + List(domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) + Update(domain.SCUMPlayerLiveState) error +} +type SCUMSquadRepository interface { + Create(domain.SCUMSquad) error + Get(string) (domain.SCUMSquad, error) + List(domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) + Update(domain.SCUMSquad) error +} +type SCUMSquadMemberRepository interface { + Create(domain.SCUMSquadMember) error + Get(string) (domain.SCUMSquadMember, error) + List(domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) + Update(domain.SCUMSquadMember) error +} +type SCUMVehicleRepository interface { + Create(domain.SCUMVehicle) error + Get(string) (domain.SCUMVehicle, error) + List(domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) + Update(domain.SCUMVehicle) error +} +type SCUMFlagRepository interface { + Create(domain.SCUMFlag) error + Get(string) (domain.SCUMFlag, error) + List(domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) + Update(domain.SCUMFlag) error +} +type SCUMCurrentPositionRepository interface { + Create(domain.SCUMCurrentPosition) error + Get(string) (domain.SCUMCurrentPosition, error) + List(domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) + Update(domain.SCUMCurrentPosition) error +} +type SCUMOperationRequestRepository interface { + Create(domain.SCUMOperationRequest) error + Get(string) (domain.SCUMOperationRequest, error) + List(domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) + Update(domain.SCUMOperationRequest) error +} +type SCUMWorkflowInstanceRepository interface { + Create(domain.SCUMWorkflowInstance) error + Get(string) (domain.SCUMWorkflowInstance, error) + List(domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) + Update(domain.SCUMWorkflowInstance) error +} +type SCUMWorkflowStepRepository interface { + Create(domain.SCUMWorkflowStep) error + Get(string) (domain.SCUMWorkflowStep, error) + List(domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) + Update(domain.SCUMWorkflowStep) error +} type Store interface { Users() UserRepository @@ -337,6 +397,16 @@ type Store interface { GameGiftCatalogs() GameGiftCatalogRepository GameGiftRevisions() GameGiftRevisionRepository GameGiftGrants() GameGiftGrantRepository + SCUMDataObservations() SCUMDataObservationRepository + SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository + SCUMSquads() SCUMSquadRepository + SCUMSquadMembers() SCUMSquadMemberRepository + SCUMVehicles() SCUMVehicleRepository + SCUMFlags() SCUMFlagRepository + SCUMCurrentPositions() SCUMCurrentPositionRepository + SCUMOperationRequests() SCUMOperationRequestRepository + SCUMWorkflowInstances() SCUMWorkflowInstanceRepository + SCUMWorkflowSteps() SCUMWorkflowStepRepository } type MemoryStore struct { @@ -380,6 +450,16 @@ type MemoryStore struct { gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter] gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter] gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter] + scumDataObservations *memoryRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter] + scumPlayerLiveStates *memoryRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter] + scumSquads *memoryRepository[domain.SCUMSquad, domain.SCUMProjectionFilter] + scumSquadMembers *memoryRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter] + scumVehicles *memoryRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter] + scumFlags *memoryRepository[domain.SCUMFlag, domain.SCUMProjectionFilter] + scumCurrentPositions *memoryRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter] + scumOperationRequests *memoryRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter] + scumWorkflowInstances *memoryRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter] + scumWorkflowSteps *memoryRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter] } func NewMemoryStore() *MemoryStore { @@ -528,6 +608,16 @@ func NewMemoryStore() *MemoryStore { gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog), gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision), gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant), + scumDataObservations: newMemoryRepository(func(v domain.SCUMDataObservation) string { return v.ID }, domain.CopySCUMDataObservation, matchSCUMDataObservation), + scumPlayerLiveStates: newMemoryRepository(func(v domain.SCUMPlayerLiveState) string { return v.ID }, domain.CopySCUMPlayerLiveState, matchSCUMPlayerLiveState), + scumSquads: newMemoryRepository(func(v domain.SCUMSquad) string { return v.ID }, domain.CopySCUMSquad, matchSCUMSquad), + scumSquadMembers: newMemoryRepository(func(v domain.SCUMSquadMember) string { return v.ID }, domain.CopySCUMSquadMember, matchSCUMSquadMember), + scumVehicles: newMemoryRepository(func(v domain.SCUMVehicle) string { return v.ID }, domain.CopySCUMVehicle, matchSCUMVehicle), + scumFlags: newMemoryRepository(func(v domain.SCUMFlag) string { return v.ID }, domain.CopySCUMFlag, matchSCUMFlag), + scumCurrentPositions: newMemoryRepository(func(v domain.SCUMCurrentPosition) string { return v.ID }, domain.CopySCUMCurrentPosition, matchSCUMCurrentPosition), + scumOperationRequests: newMemoryRepository(func(v domain.SCUMOperationRequest) string { return v.ID }, domain.CopySCUMOperationRequest, matchSCUMOperationRequest), + scumWorkflowInstances: newMemoryRepository(func(v domain.SCUMWorkflowInstance) string { return v.ID }, domain.CopySCUMWorkflowInstance, matchSCUMWorkflowInstance), + scumWorkflowSteps: newMemoryRepository(func(v domain.SCUMWorkflowStep) string { return v.ID }, domain.CopySCUMWorkflowStep, matchSCUMWorkflowStep), } } @@ -607,6 +697,30 @@ func (store *MemoryStore) GameGiftRevisions() GameGiftRevisionRepository { return store.gameGiftRevisions } func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return store.gameGiftGrants } +func (store *MemoryStore) SCUMDataObservations() SCUMDataObservationRepository { + return store.scumDataObservations +} +func (store *MemoryStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { + return store.scumPlayerLiveStates +} +func (store *MemoryStore) SCUMSquads() SCUMSquadRepository { return store.scumSquads } +func (store *MemoryStore) SCUMSquadMembers() SCUMSquadMemberRepository { + return store.scumSquadMembers +} +func (store *MemoryStore) SCUMVehicles() SCUMVehicleRepository { return store.scumVehicles } +func (store *MemoryStore) SCUMFlags() SCUMFlagRepository { return store.scumFlags } +func (store *MemoryStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { + return store.scumCurrentPositions +} +func (store *MemoryStore) SCUMOperationRequests() SCUMOperationRequestRepository { + return store.scumOperationRequests +} +func (store *MemoryStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { + return store.scumWorkflowInstances +} +func (store *MemoryStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { + return store.scumWorkflowSteps +} type memoryRepository[T any, F any] struct { mu sync.RWMutex @@ -941,3 +1055,100 @@ func matchGameGiftRevision(v domain.GameGiftRevision, f domain.GameGiftRevisionF func matchGameGiftGrant(v domain.GameGiftGrant, f domain.GameGiftGrantFilter) bool { return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) } + +func matchSCUMDataObservation(v domain.SCUMDataObservation, f domain.SCUMProjectionFilter) bool { + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.SubjectType == "" || v.SubjectType == string(f.SubjectType)) && + (f.GamePlayerRecordID == "" || v.SubjectID == f.GamePlayerRecordID) && + (f.QueryKey == "" || v.QueryKey == f.QueryKey) && + (f.Freshness == "" || domain.SCUMProjectionFreshness(v.Status) == f.Freshness) +} + +func matchSCUMPlayerLiveState(v domain.SCUMPlayerLiveState, f domain.SCUMProjectionFilter) bool { + search := strings.ToLower(strings.TrimSpace(f.Search)) + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && + (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && + (f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) && + (f.SteamID == "" || v.SteamID == f.SteamID) && + (f.SquadID == "" || v.SquadID == f.SquadID) && + (f.Freshness == "" || v.Freshness.Status == f.Freshness) && + (search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search) || strings.Contains(strings.ToLower(v.SteamID), search)) +} + +func matchSCUMSquad(v domain.SCUMSquad, f domain.SCUMProjectionFilter) bool { + search := strings.ToLower(strings.TrimSpace(f.Search)) + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.SquadID == "" || v.SquadID == f.SquadID) && + (f.UserProfileID == "" || v.LeaderProfileID == f.UserProfileID) && + (f.Freshness == "" || v.Freshness.Status == f.Freshness) && + (search == "" || strings.Contains(strings.ToLower(v.Name), search) || strings.Contains(strings.ToLower(v.SquadID), search)) +} + +func matchSCUMSquadMember(v domain.SCUMSquadMember, f domain.SCUMProjectionFilter) bool { + search := strings.ToLower(strings.TrimSpace(f.Search)) + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.SquadID == "" || v.SquadID == f.SquadID) && + (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && + (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && + (f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) && + (f.SteamID == "" || v.SteamID == f.SteamID) && + (f.Freshness == "" || v.Freshness.Status == f.Freshness) && + (search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search)) +} + +func matchSCUMVehicle(v domain.SCUMVehicle, f domain.SCUMProjectionFilter) bool { + search := strings.ToLower(strings.TrimSpace(f.Search)) + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.VehicleID == "" || v.VehicleID == f.VehicleID) && + (f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) && + (f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) && + (f.SquadID == "" || v.SquadID == f.SquadID) && + (f.Freshness == "" || v.Freshness.Status == f.Freshness) && + (search == "" || strings.Contains(strings.ToLower(v.Label), search) || strings.Contains(strings.ToLower(v.ClassName), search) || strings.Contains(strings.ToLower(v.VehicleID), search)) +} + +func matchSCUMFlag(v domain.SCUMFlag, f domain.SCUMProjectionFilter) bool { + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.FlagID == "" || v.FlagID == f.FlagID) && + (f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) && + (f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) && + (f.SquadID == "" || v.OwnerSquadID == f.SquadID) && + (f.Freshness == "" || v.Freshness.Status == f.Freshness) +} + +func matchSCUMCurrentPosition(v domain.SCUMCurrentPosition, f domain.SCUMProjectionFilter) bool { + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.SubjectType == "" || v.SubjectType == f.SubjectType) && + (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && + (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && + (f.VehicleID == "" || v.VehicleID == f.VehicleID) && + (f.Freshness == "" || v.Freshness.Status == f.Freshness) +} + +func matchSCUMOperationRequest(v domain.SCUMOperationRequest, f domain.SCUMOperationRequestFilter) bool { + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.PluginID == "" || v.PluginID == f.PluginID) && + (f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) && + (f.PlayerID == "" || v.PlayerID == f.PlayerID) && + (f.RequesterID == "" || v.RequesterID == f.RequesterID) && + (f.Status == "" || v.Status == f.Status) && + (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) +} + +func matchSCUMWorkflowInstance(v domain.SCUMWorkflowInstance, f domain.SCUMWorkflowInstanceFilter) bool { + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.PluginID == "" || v.PluginID == f.PluginID) && + (f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) && + (f.RequestedBy == "" || v.RequestedBy == f.RequestedBy) && + (f.Status == "" || v.Status == f.Status) && + (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) +} + +func matchSCUMWorkflowStep(v domain.SCUMWorkflowStep, f domain.SCUMWorkflowStepFilter) bool { + return (f.WorkflowID == "" || v.WorkflowID == f.WorkflowID) && + (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && + (f.StepKey == "" || v.StepKey == f.StepKey) && + (f.Status == "" || v.Status == f.Status) && + (f.MutatesState == nil || v.MutatesState == *f.MutatesState) +} diff --git a/platform/repo/scum_projections_test.go b/platform/repo/scum_projections_test.go new file mode 100644 index 0000000..820b086 --- /dev/null +++ b/platform/repo/scum_projections_test.go @@ -0,0 +1,67 @@ +package repo + +import ( + "path/filepath" + "testing" + "time" + + "browser.local/platform/domain" +) + +func TestSCUMProjectionRepositoriesCopyFilterAndPersist(t *testing.T) { + path := filepath.Join(t.TempDir(), "metadata.json") + store, err := NewFileStore(path) + if err != nil { + t.Fatalf("create file store: %v", err) + } + stamp := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: "obs-1", Source: "run", QueryKey: "scum.player.profile", Sequence: 7, Checksum: "sha256:projection", ObservedAt: stamp, ReceivedAt: stamp.Add(time.Second)} + state := domain.SCUMPlayerLiveState{ID: "state-1", ServerInstanceID: "server-1", GamePlayerRecordID: "game-player-1", GamePlayerID: "steam-1", UserProfileID: "profile-1", SteamID: "steam-1", DisplayName: "Moon", SquadID: "squad-1", UnknownFields: map[string]any{"schemaField": "kept"}, Freshness: freshness, CreatedAt: stamp, UpdatedAt: stamp} + if err := store.SCUMPlayerLiveStates().Create(state); err != nil { + t.Fatalf("create state: %v", err) + } + got, err := store.SCUMPlayerLiveStates().Get(state.ID) + if err != nil { + t.Fatalf("get state: %v", err) + } + got.UnknownFields["schemaField"] = "mutated" + again, err := store.SCUMPlayerLiveStates().Get(state.ID) + if err != nil { + t.Fatalf("get state again: %v", err) + } + if again.UnknownFields["schemaField"] != "kept" { + t.Fatalf("state was not copy-isolated: %+v", again.UnknownFields) + } + filtered, err := store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-1", Search: "moon"}) + if err != nil || len(filtered) != 1 { + t.Fatalf("filter states=%+v err=%v", filtered, err) + } + if err := store.SCUMSquads().Create(domain.SCUMSquad{ID: "squad-1", ServerInstanceID: "server-1", SquadID: "squad-1", Name: "Crystal", Freshness: freshness}); err != nil { + t.Fatalf("create squad: %v", err) + } + if err := store.SCUMVehicles().Create(domain.SCUMVehicle{ID: "vehicle-1", ServerInstanceID: "server-1", VehicleID: "veh-1", Label: "Unknown vehicle", Freshness: freshness}); err != nil { + t.Fatalf("create vehicle: %v", err) + } + if err := store.SCUMFlags().Create(domain.SCUMFlag{ID: "flag-1", ServerInstanceID: "server-1", FlagID: "flag-1", OwnerSquadID: "squad-1", Freshness: freshness}); err != nil { + t.Fatalf("create flag: %v", err) + } + if err := store.SCUMCurrentPositions().Create(domain.SCUMCurrentPosition{ID: "position-1", ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer, SubjectID: "steam-1", GamePlayerRecordID: "game-player-1", X: 1, Y: 2, HasCoordinates: true, Freshness: freshness}); err != nil { + t.Fatalf("create position: %v", err) + } + reloaded, err := NewFileStore(path) + if err != nil { + t.Fatalf("reload file store: %v", err) + } + reloadedStates, err := reloaded.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SquadID: "squad-1"}) + if err != nil || len(reloadedStates) != 1 || reloadedStates[0].Freshness.QueryKey != "scum.player.profile" { + t.Fatalf("unexpected reloaded states=%+v err=%v", reloadedStates, err) + } + vehicles, err := reloaded.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", Search: "unknown"}) + if err != nil || len(vehicles) != 1 { + t.Fatalf("unexpected reloaded vehicles=%+v err=%v", vehicles, err) + } + positions, err := reloaded.SCUMCurrentPositions().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer}) + if err != nil || len(positions) != 1 || !positions[0].HasCoordinates { + t.Fatalf("unexpected reloaded positions=%+v err=%v", positions, err) + } +} diff --git a/platform/service/game_players.go b/platform/service/game_players.go index 7dd2053..f6946bb 100644 --- a/platform/service/game_players.go +++ b/platform/service/game_players.go @@ -125,6 +125,9 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr if eventType == "scum.login" { outcome := strings.TrimSpace(fields["outcome"]) if outcome == "accepted" { + if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, true, ""); err != nil { + return err + } if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil { return err } @@ -132,6 +135,9 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr } return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])) } + if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, false, strings.TrimSpace(fields["reason"])); err != nil { + return err + } return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"])) } diff --git a/platform/service/resources.go b/platform/service/resources.go index 0ea7dc1..274828d 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -225,6 +225,20 @@ type Core interface { RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error) ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error) ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error) + ListSCUMPlayerLiveStatesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) + ListSCUMSquadsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) + ListSCUMSquadMembersForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) + ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) + ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) + ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) + RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) + ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) + ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error) + ReconcileSCUMOperation(string) (domain.SCUMOperationRequest, error) + ConfirmSCUMOperation(string, domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) + CreateSCUMWorkflowForSession(string, string, domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) + ListSCUMWorkflowsForSession(string, domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) + ListSCUMWorkflowStepsForSession(string, domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error) GetAuditEvent(string) (domain.AuditEvent, error) ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) diff --git a/platform/service/scum_operations.go b/platform/service/scum_operations.go new file mode 100644 index 0000000..ed2b676 --- /dev/null +++ b/platform/service/scum_operations.go @@ -0,0 +1,770 @@ +package service + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +type scumSQLiteMutationJobResult struct { + Outcome string `json:"outcome"` + AffectedRows int `json:"affectedRows"` + MutationChecksum string `json:"mutationChecksum"` + ConfirmationRows []map[string]any `json:"confirmationRows"` + SafeMessage string `json:"safeMessage"` +} + +func (svc *CoreService) RequestSCUMOperationForSession(sessionID, serverID string, request domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) { + request = domain.CopySCUMOperationRequest(request) + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { + return domain.SCUMOperationRequest{}, err + } + instance, err := svc.store.ServerInstances().Get(serverID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + template, ok := scumOperationTemplate(plugin, request.TemplateKey) + if !ok { + return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared") + } + if !containsString(plugin.DeclaredPermissions, template.Permission) { + return domain.SCUMOperationRequest{}, validationError("SCUM operation permission is not declared") + } + if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { + return domain.SCUMOperationRequest{}, validationError("operation idempotency key is required") + } + existing, err := svc.store.SCUMOperationRequests().List(domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + if len(existing) > 0 { + return domain.CopySCUMOperationRequest(existing[0]), nil + } + playerID := coalesceString(request.PlayerID, firstString(request.Payload, "playerId", "steamId")) + if playerID == "" && request.TemplateKey != "server.reward.command.deliver" { + return domain.SCUMOperationRequest{}, validationError("operation playerId is required") + } + summary := operationSafeSummary(request.TemplateKey, playerID, request.Payload) + switch template.Kind { + case domain.GameClientBridgeOperationKindRCON: + if err := validateSCUMRCONOperationPayload(request.TemplateKey, playerID, request.Payload); err != nil { + return domain.SCUMOperationRequest{}, err + } + case domain.GameClientBridgeOperationKindSQLiteMutation: + guard, payload, err := normalizeSCUMSQLiteMutationRequest(template, playerID, request.Payload, request.Guard) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + request.Guard = guard + request.Payload = payload + summary = scumSQLiteMutationSafeSummary(request.TemplateKey, playerID, guard) + default: + return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported") + } + stamp := svc.now() + operation := domain.SCUMOperationRequest{ID: "scum-operation-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: instance.PluginID, TemplateKey: request.TemplateKey, PlayerID: playerID, RequesterID: user.ID, ApprovalLevel: template.ApprovalLevel, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: request.Guard, Status: domain.SCUMWorkflowStepWaiting, Reason: bounded(request.Reason, 240), IdempotencyKey: request.IdempotencyKey, SafeSummary: summary, CreatedAt: stamp, UpdatedAt: stamp} + if err := svc.store.SCUMOperationRequests().Create(operation); err != nil { + return domain.SCUMOperationRequest{}, err + } + _, err = svc.recordAuditEventWithID(user.ID, "scum.operation.request", "scum-operation", operation.ID, domain.AuditResultQueued, "typed SCUM operation awaiting approval") + return domain.CopySCUMOperationRequest(operation), err +} + +func (svc *CoreService) ListSCUMOperationsForSession(sessionID string, filter domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMOperationRequests().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) ApproveSCUMOperationForSession(sessionID, operationID string) (domain.SCUMOperationRequest, error) { + operation, err := svc.store.SCUMOperationRequests().Get(operationID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + if err := svc.authorizeServerLifecycle(sessionID, operation.ServerInstanceID); err != nil { + return domain.SCUMOperationRequest{}, err + } + if operation.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin && !isPlatformAdmin(user) { + return domain.SCUMOperationRequest{}, ErrForbidden + } + if operation.Status != domain.SCUMWorkflowStepWaiting { + return domain.SCUMOperationRequest{}, validationError("SCUM operation is not awaiting approval") + } + instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + template, ok := scumOperationTemplate(plugin, operation.TemplateKey) + if !ok { + return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared") + } + var jobID string + var auditSummary string + switch template.Kind { + case domain.GameClientBridgeOperationKindRCON: + request, err := svc.sourceRCONRequestForSCUMOperation(operation) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, request) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + jobID = dispatch.JobID + auditSummary = "typed SCUM operation dispatched through transient RCON input" + case domain.GameClientBridgeOperationKindSQLiteMutation: + gated, ready, err := svc.applySCUMSQLiteMutationApprovalGate(operation, template) + if err != nil || !ready { + return gated, err + } + operation = gated + job, err := svc.dispatchSCUMSQLiteMutationOperation(operation, template) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + jobID = job.ID + auditSummary = "typed SCUM DB mutation dispatched through template-bound Run job" + default: + return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported") + } + stamp := svc.now() + operation.ApproverID = user.ID + operation.ApprovedAt = stamp + operation.Status = domain.SCUMWorkflowStepQueued + operation.RunJobID = jobID + operation.UpdatedAt = stamp + operation.AuditReferences = append(operation.AuditReferences, "job:"+jobID) + if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { + return domain.SCUMOperationRequest{}, err + } + _, err = svc.recordAuditEventWithID(user.ID, "scum.operation.approve", "scum-operation", operation.ID, domain.AuditResultQueued, auditSummary) + return domain.CopySCUMOperationRequest(operation), err +} + +func (svc *CoreService) ReconcileSCUMOperation(operationID string) (domain.SCUMOperationRequest, error) { + operation, err := svc.store.SCUMOperationRequests().Get(operationID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + if strings.TrimSpace(operation.RunJobID) == "" { + return domain.CopySCUMOperationRequest(operation), nil + } + job, err := svc.store.Jobs().Get(operation.RunJobID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + template, _ := scumOperationTemplate(plugin, operation.TemplateKey) + stamp := svc.now() + switch job.State { + case domain.JobStateSucceeded: + if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation { + if updated, terminal := reconcileSCUMSQLiteMutationJobResult(operation, template, job); terminal { + operation = updated + } else { + operation = updated + operation.Status = domain.SCUMWorkflowStepConfirming + } + } else if operation.Confirmation.Status == "confirmed" { + operation.Status = domain.SCUMWorkflowStepConfirmed + } else { + operation.Status = domain.SCUMWorkflowStepConfirming + } + case domain.JobStateFailed: + if strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") || strings.Contains(strings.ToLower(job.ExecutionResult.AuditSummary), "unknown") { + operation.Status = domain.SCUMWorkflowStepUnknown + } else { + operation.Status = domain.SCUMWorkflowStepFailed + } + operation.CompletedAt = stamp + case domain.JobStateCancelled: + operation.Status = domain.SCUMWorkflowStepUnknown + operation.CompletedAt = stamp + } + operation.UpdatedAt = stamp + if (operation.Status == domain.SCUMWorkflowStepConfirmed || operation.Status == domain.SCUMWorkflowStepFailed || operation.Status == domain.SCUMWorkflowStepUnknown) && operation.CompletedAt.IsZero() { + operation.CompletedAt = stamp + } + if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { + return domain.SCUMOperationRequest{}, err + } + return domain.CopySCUMOperationRequest(operation), nil +} + +func (svc *CoreService) ConfirmSCUMOperation(operationID string, confirmation domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) { + operation, err := svc.store.SCUMOperationRequests().Get(operationID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.SCUMOperationRequest{}, err + } + template, _ := scumOperationTemplate(plugin, operation.TemplateKey) + confirmation = domain.CopySCUMOperationConfirmation(confirmation) + stamp := svc.now() + if confirmation.Status != "confirmed" { + operation.Status = domain.SCUMWorkflowStepFailed + operation.Confirmation = confirmation + operation.CompletedAt = stamp + operation.UpdatedAt = stamp + if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { + return domain.SCUMOperationRequest{}, err + } + return domain.CopySCUMOperationRequest(operation), nil + } + if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation && !scumSQLiteMutationConfirmationMatches(operation, confirmation.ConfirmedFields) { + confirmation.Status = "failed" + confirmation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run readback did not prove the requested SCUM player field value."} + operation.Status = domain.SCUMWorkflowStepFailed + operation.Confirmation = confirmation + operation.CompletedAt = stamp + operation.UpdatedAt = stamp + if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { + return domain.SCUMOperationRequest{}, err + } + return domain.CopySCUMOperationRequest(operation), nil + } + operation.Confirmation = confirmation + operation.Status = domain.SCUMWorkflowStepConfirmed + operation.CompletedAt = stamp + operation.UpdatedAt = stamp + if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { + return domain.SCUMOperationRequest{}, err + } + return domain.CopySCUMOperationRequest(operation), nil +} + +func (svc *CoreService) sourceRCONRequestForSCUMOperation(operation domain.SCUMOperationRequest) (domain.SourceRCONCommandRequest, error) { + command, chat, err := scumRCONCommandForOperation(operation) + if err != nil { + return domain.SourceRCONCommandRequest{}, err + } + request := domain.SourceRCONCommandRequest{ServerInstanceID: operation.ServerInstanceID, IdempotencyKey: "scum-operation-" + operation.IdempotencyKey} + if chat != "" { + request.Kind = domain.SourceRCONCommandKindChat + request.ChatType = 4 + request.TargetSteamID = operation.PlayerID + request.Message = chat + return request, nil + } + request.Kind = domain.SourceRCONCommandKindCommand + request.Command = command + return request, nil +} + +func scumRCONCommandForOperation(operation domain.SCUMOperationRequest) (command string, chat string, err error) { + playerID := operation.PlayerID + switch operation.TemplateKey { + case "player.fame.set": + amount, ok := operationInteger(operation.Payload, "fame", "amount", "value") + if !ok { + return "", "", validationError("fame amount is required") + } + return fmt.Sprintf("#SetFamePoints %d %q", amount, playerID), "", nil + case "player.currency.normal.set": + amount, ok := operationInteger(operation.Payload, "amount", "balance", "normalBalance") + if !ok { + return "", "", validationError("normal currency amount is required") + } + return fmt.Sprintf("#SetCurrencyBalance Normal %d %q", amount, playerID), "", nil + case "player.currency.gold.set": + amount, ok := operationInteger(operation.Payload, "amount", "balance", "goldBalance") + if !ok { + return "", "", validationError("gold currency amount is required") + } + return fmt.Sprintf("#SetCurrencyBalance Gold %d %q", amount, playerID), "", nil + case "player.notify": + message := strings.TrimSpace(firstString(operation.Payload, "message", "notice")) + if message == "" || len(message) > 200 { + return "", "", validationError("notification message is required") + } + return "", message, nil + default: + return "", "", validationError("unsupported SCUM RCON operation template") + } +} + +func validateSCUMRCONOperationPayload(templateKey, playerID string, payload map[string]any) error { + operation := domain.SCUMOperationRequest{TemplateKey: templateKey, PlayerID: playerID, Payload: payload} + command, chat, err := scumRCONCommandForOperation(operation) + if err != nil { + return err + } + if strings.ContainsAny(command, "\r\n") || strings.ContainsAny(chat, "\r\n") { + return validationError("operation payload contains invalid control characters") + } + return nil +} + +func normalizeSCUMSQLiteMutationRequest(template domain.GameClientBridgeOperationTemplateDeclaration, playerID string, payload map[string]any, guard domain.SCUMMutationGuard) (domain.SCUMMutationGuard, map[string]any, error) { + lowerKey := strings.ToLower(template.Key) + if strings.Contains(lowerKey, "fame") || strings.Contains(lowerKey, "currency") { + return domain.SCUMMutationGuard{}, nil, validationError("SCUM fame and currency edits must use RCON operation templates") + } + if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { + return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation requires platform-admin approval") + } + if template.Mutation.FieldKey == "" || template.Mutation.ConfirmationQueryKey == "" || template.Mutation.TableKey == "" || template.Mutation.IdentityKey == "" || template.Mutation.ValueKey == "" { + return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation metadata is incomplete") + } + if template.MaxRowsAffected < 1 { + return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation row bound is required") + } + payload = domain.CopyGameClientBridgePayload(payload) + if guard.FieldKey == "" { + guard.FieldKey = coalesceString(firstString(payload, "fieldKey"), template.Mutation.FieldKey) + } + if guard.Before == nil { + guard.Before = payload["before"] + } + if guard.After == nil { + guard.After = payload["after"] + if guard.After == nil { + guard.After = payload["value"] + } + } + if guard.MaxRowsAffected == 0 { + guard.MaxRowsAffected = template.MaxRowsAffected + } + guard.SafetyWindow = coalesceString(guard.SafetyWindow, firstString(payload, "safetyWindow", "maintenanceWindow")) + guard.BackupRef = coalesceString(guard.BackupRef, firstString(payload, "backupRef", "snapshotRef")) + guard.RequiresOfflinePlayer = template.Safety.RequiresOfflinePlayer + guard.RequiresMaintenance = template.Safety.RequiresMaintenanceWindow + guard.RequiresBackup = template.Safety.BackupRequired + if playerID == "" { + return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation playerId is required") + } + if guard.FieldKey != template.Mutation.FieldKey { + return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation field key does not match template") + } + if guard.Before == nil || guard.After == nil { + return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation before and after values are required") + } + if guard.MaxRowsAffected < 1 || guard.MaxRowsAffected > template.MaxRowsAffected { + return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation maxRowsAffected exceeds template bound") + } + if err := validateSCUMMutationValue(template, guard.Before, "before"); err != nil { + return domain.SCUMMutationGuard{}, nil, err + } + if err := validateSCUMMutationValue(template, guard.After, "after"); err != nil { + return domain.SCUMMutationGuard{}, nil, err + } + for key, value := range map[string]any{"playerId": playerID, "fieldKey": guard.FieldKey, "before": guard.Before, "after": guard.After, "safetyWindow": guard.SafetyWindow, "backupRef": guard.BackupRef} { + if value != nil && value != "" { + payload[key] = value + } + } + return guard, payload, nil +} + +func validateSCUMMutationValue(template domain.GameClientBridgeOperationTemplateDeclaration, value any, label string) error { + switch template.Mutation.AllowedValueType { + case "integer": + parsed, ok := anyInt64(value) + if !ok { + return validationError("SCUM DB mutation " + label + " value must be an integer") + } + if template.Mutation.MinValue != 0 && float64(parsed) < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && float64(parsed) > template.Mutation.MaxValue { + return validationError("SCUM DB mutation " + label + " value is outside the template range") + } + case "number": + parsed, ok := anyFloat64(value) + if !ok { + return validationError("SCUM DB mutation " + label + " value must be numeric") + } + if template.Mutation.MinValue != 0 && parsed < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && parsed > template.Mutation.MaxValue { + return validationError("SCUM DB mutation " + label + " value is outside the template range") + } + case "string": + if strings.TrimSpace(fmt.Sprint(value)) == "" || strings.ContainsAny(fmt.Sprint(value), "\r\n") { + return validationError("SCUM DB mutation " + label + " value is invalid") + } + case "boolean": + if _, ok := value.(bool); !ok { + return validationError("SCUM DB mutation " + label + " value must be boolean") + } + default: + return validationError("SCUM DB mutation value type is unsupported") + } + return nil +} + +func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.SCUMOperationRequest, bool, error) { + state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID) + if err != nil { + if err == repo.ErrNotFound { + return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待真实玩家投影", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。") + } + return domain.SCUMOperationRequest{}, false, err + } + if state.Freshness.Status != domain.SCUMProjectionFresh { + return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待新鲜投影", "玩家投影不是 fresh,需先刷新 SCUM.db/readback。") + } + if template.Safety.RequiresOfflinePlayer && state.Online { + return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。") + } + if template.Safety.RequiresMaintenanceWindow && strings.TrimSpace(operation.Guard.SafetyWindow) == "" { + return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少维护窗口", "DB mutation 需要记录维护窗口/离线安全证据。") + } + if template.Safety.BackupRequired && strings.TrimSpace(operation.Guard.BackupRef) == "" { + return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少备份快照", "DB mutation 需要 run 或管理员提供 backup/snapshot evidence。") + } + current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey) + if !ok { + return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前投影没有该 DB-only 字段,需先执行确认查询。") + } + if !scumScalarEqual(current, operation.Guard.Before) { + return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前投影值与审批时 before guard 不一致,已阻止写入。") + } + return domain.CopySCUMOperationRequest(operation), true, nil +} + +func (svc *CoreService) updateSCUMOperationGate(operation domain.SCUMOperationRequest, status domain.SCUMWorkflowStepStatus, title string, message string) (domain.SCUMOperationRequest, bool, error) { + operation.Status = status + operation.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"template": operation.TemplateKey, "playerId": operation.PlayerID}} + operation.UpdatedAt = svc.now() + if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { + return domain.SCUMOperationRequest{}, false, err + } + return domain.CopySCUMOperationRequest(operation), false, nil +} + +func (svc *CoreService) latestSCUMPlayerLiveState(serverID, playerID string) (domain.SCUMPlayerLiveState, error) { + states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: playerID}) + if err != nil { + return domain.SCUMPlayerLiveState{}, err + } + if len(states) == 0 { + states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, SteamID: playerID}) + if err != nil { + return domain.SCUMPlayerLiveState{}, err + } + } + if len(states) == 0 { + return domain.SCUMPlayerLiveState{}, repo.ErrNotFound + } + best := states[0] + for _, state := range states[1:] { + if state.Freshness.ObservedAt.After(best.Freshness.ObservedAt) || state.UpdatedAt.After(best.UpdatedAt) { + best = state + } + } + return domain.CopySCUMPlayerLiveState(best), nil +} + +func scumCurrentMutationFieldValue(state domain.SCUMPlayerLiveState, fieldKey string) (any, bool) { + if state.UnknownFields != nil { + for _, key := range []string{fieldKey, "field" + fieldKey, "attribute" + fieldKey, "attribute_" + fieldKey, "stat" + fieldKey, "stat_" + fieldKey} { + if value, ok := state.UnknownFields[key]; ok { + return value, true + } + } + } + return nil, false +} + +func (svc *CoreService) dispatchSCUMSQLiteMutationOperation(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.Job, error) { + instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) + if err != nil { + return domain.Job{}, err + } + jobID := jobIDFromParts("job-scum-sqlite-mutation", instance.ID, operation.IdempotencyKey) + job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: template.TargetKey, InputRef: "input://scum-operation/" + operation.ID, IdempotencyKey: "scum-sqlite-mutation:" + operation.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "typed SCUM DB mutation queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: "protected-sql", TimeoutSeconds: template.TimeoutSeconds, PluginID: operation.PluginID, Inputs: scumSQLiteMutationJobInputs(operation, template)}} + created, err := svc.CreateJob(job) + if err != nil { + return domain.Job{}, err + } + if created.ID != jobID || created.Capability != domain.JobCapabilityRemoteRunProtectedSQL || created.TargetKey != template.TargetKey || created.ExecutionInput.RemoteAdapterKey != template.TransportKey { + return domain.Job{}, validationError("SCUM DB mutation idempotency key is already bound") + } + return created, nil +} + +func scumSQLiteMutationJobInputs(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) map[string]string { + return map[string]string{ + "operationId": operation.ID, + "templateKey": operation.TemplateKey, + "playerId": operation.PlayerID, + "fieldKey": operation.Guard.FieldKey, + "tableKey": template.Mutation.TableKey, + "identityKey": template.Mutation.IdentityKey, + "valueKey": template.Mutation.ValueKey, + "before": scumScalarString(operation.Guard.Before), + "after": scumScalarString(operation.Guard.After), + "maxRowsAffected": strconv.Itoa(operation.Guard.MaxRowsAffected), + "confirmationQueryKey": template.Mutation.ConfirmationQueryKey, + "safetyWindow": operation.Guard.SafetyWindow, + "backupRef": operation.Guard.BackupRef, + } +} + +func reconcileSCUMSQLiteMutationJobResult(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration, job domain.Job) (domain.SCUMOperationRequest, bool) { + result, ok := parseSCUMSQLiteMutationJobResult(job.ExecutionResult.Content) + if !ok || result.Outcome == "unknown" || strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") { + operation.Status = domain.SCUMWorkflowStepUnknown + operation.Confirmation = domain.SCUMOperationConfirmation{Status: "unknown", SafeSummary: domain.SCUMSafeSummary{Title: "DB mutation state unknown", Message: "Run did not return a valid bounded mutation result."}} + return operation, true + } + operation.Confirmation.AffectedRows = result.AffectedRows + operation.Confirmation.MutationChecksum = result.MutationChecksum + operation.Confirmation.Checksum = coalesceString(operation.Confirmation.Checksum, coalesceString(result.MutationChecksum, job.ExecutionResult.Checksum)) + if result.Outcome == "stale-before" { + operation.Status = domain.SCUMWorkflowStepFailed + operation.Confirmation.Status = "failed" + operation.SafeSummary = domain.SCUMSafeSummary{Title: "before value 已过期", Message: "Run 在写入前发现当前 DB 值与 approved before guard 不一致。"} + return operation, true + } + if result.Outcome != "succeeded" || result.AffectedRows < 1 { + operation.Status = domain.SCUMWorkflowStepFailed + operation.Confirmation.Status = "failed" + operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation failed", Message: bounded(coalesceString(result.SafeMessage, "Run reported the mutation did not succeed."), 240)} + return operation, true + } + if result.AffectedRows > template.MaxRowsAffected || result.AffectedRows > operation.Guard.MaxRowsAffected || strings.TrimSpace(result.MutationChecksum) == "" { + operation.Status = domain.SCUMWorkflowStepUnknown + operation.Confirmation.Status = "unknown" + operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation row bound unknown", Message: "Run result exceeded declared row bounds or omitted mutation checksum."} + return operation, true + } + if len(result.ConfirmationRows) > 0 { + for _, row := range result.ConfirmationRows { + if scumSQLiteMutationConfirmationMatches(operation, row) { + operation.Status = domain.SCUMWorkflowStepConfirmed + operation.Confirmation.Status = "confirmed" + operation.Confirmation.ConfirmedFields = domain.CopyGameClientBridgePayload(row) + return operation, true + } + } + operation.Status = domain.SCUMWorkflowStepFailed + operation.Confirmation.Status = "failed" + operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run confirmation rows did not match the requested after value."} + return operation, true + } + operation.Confirmation.Status = "executed" + return operation, false +} + +func parseSCUMSQLiteMutationJobResult(content string) (scumSQLiteMutationJobResult, bool) { + if strings.TrimSpace(content) == "" { + return scumSQLiteMutationJobResult{}, false + } + var result scumSQLiteMutationJobResult + if err := json.Unmarshal([]byte(content), &result); err != nil { + return scumSQLiteMutationJobResult{}, false + } + result.Outcome = strings.TrimSpace(result.Outcome) + return result, result.Outcome != "" +} + +func scumSQLiteMutationConfirmationMatches(operation domain.SCUMOperationRequest, row map[string]any) bool { + if row == nil { + return false + } + rowPlayerID := firstString(row, "playerId", "gamePlayerId", "steamId", "steam_id") + if rowPlayerID != "" && rowPlayerID != operation.PlayerID { + return false + } + if field := firstString(row, "fieldKey", "field", "attributeKey"); field != "" && field != operation.Guard.FieldKey { + return false + } + for _, key := range []string{"value", "after", operation.Guard.FieldKey, "field" + operation.Guard.FieldKey, "attribute" + operation.Guard.FieldKey, "attribute_" + operation.Guard.FieldKey} { + if value, ok := row[key]; ok && scumScalarEqual(value, operation.Guard.After) { + return true + } + } + return false +} + +func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SCUMMutationGuard) domain.SCUMSafeSummary { + details := map[string]string{"template": templateKey, "fieldKey": guard.FieldKey, "maxRowsAffected": strconv.Itoa(guard.MaxRowsAffected)} + if playerID != "" { + details["playerId"] = playerID + } + if guard.SafetyWindow != "" { + details["safetyWindow"] = guard.SafetyWindow + } + if guard.BackupRef != "" { + details["backupRef"] = guard.BackupRef + } + return domain.SCUMSafeSummary{Title: "Typed SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details} +} + +func operationInteger(payload map[string]any, keys ...string) (int64, bool) { + for _, key := range keys { + value, exists := payload[key] + if !exists { + continue + } + switch typed := value.(type) { + case int: + return int64(typed), true + case int64: + return typed, true + case uint64: + if typed > uint64(^uint64(0)>>1) { + return 0, false + } + return int64(typed), true + case float64: + if typed == float64(int64(typed)) { + return int64(typed), true + } + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + if err == nil { + return parsed, true + } + } + } + return 0, false +} + +func anyInt64(value any) (int64, bool) { + switch typed := value.(type) { + case int: + return int64(typed), true + case int8: + return int64(typed), true + case int16: + return int64(typed), true + case int32: + return int64(typed), true + case int64: + return typed, true + case uint: + return int64(typed), true + case uint8: + return int64(typed), true + case uint16: + return int64(typed), true + case uint32: + return int64(typed), true + case uint64: + if typed > uint64(^uint64(0)>>1) { + return 0, false + } + return int64(typed), true + case float64: + if typed == float64(int64(typed)) { + return int64(typed), true + } + case float32: + if typed == float32(int64(typed)) { + return int64(typed), true + } + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed, err == nil + } + return 0, false +} + +func anyFloat64(value any) (float64, bool) { + switch typed := value.(type) { + case int: + return float64(typed), true + case int64: + return float64(typed), true + case uint64: + return float64(typed), true + case float64: + return typed, true + case float32: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return parsed, err == nil + } + return 0, false +} + +func scumScalarEqual(left any, right any) bool { + if leftInt, ok := anyInt64(left); ok { + if rightInt, rightOK := anyInt64(right); rightOK { + return leftInt == rightInt + } + } + if leftFloat, ok := anyFloat64(left); ok { + if rightFloat, rightOK := anyFloat64(right); rightOK { + return leftFloat == rightFloat + } + } + return strings.TrimSpace(fmt.Sprint(left)) == strings.TrimSpace(fmt.Sprint(right)) +} + +func scumScalarString(value any) string { + if parsed, ok := anyInt64(value); ok { + return strconv.FormatInt(parsed, 10) + } + if parsed, ok := anyFloat64(value); ok { + return strconv.FormatFloat(parsed, 'f', -1, 64) + } + if typed, ok := value.(bool); ok { + return strconv.FormatBool(typed) + } + return bounded(strings.TrimSpace(fmt.Sprint(value)), 512) +} + +func operationSafeSummary(templateKey, playerID string, payload map[string]any) domain.SCUMSafeSummary { + details := map[string]string{"template": templateKey} + if playerID != "" { + details["playerId"] = playerID + } + if amount, ok := operationInteger(payload, "fame", "amount", "balance", "value", "normalBalance", "goldBalance"); ok { + details["value"] = fmt.Sprintf("%d", amount) + } + return domain.SCUMSafeSummary{Title: "Typed SCUM operation", Message: "RCON text is generated server-side and is not stored in the operation record.", Details: details} +} + +func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) { + for _, template := range plugin.GameClientBridge.OperationTemplates { + if template.Key == key { + return template, true + } + } + return domain.GameClientBridgeOperationTemplateDeclaration{}, false +} diff --git a/platform/service/scum_operations_test.go b/platform/service/scum_operations_test.go new file mode 100644 index 0000000..a74dd8d --- /dev/null +++ b/platform/service/scum_operations_test.go @@ -0,0 +1,273 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "browser.local/platform/domain" +) + +func TestSCUMRCONOperationApprovalDispatchesTransientCommandAndConfirms(t *testing.T) { + svc, session, runSession, instance := newSourceRCONFixture(t) + seedSCUMOperationTemplates(t, svc, instance.PluginID) + request := domain.SCUMOperationRequest{TemplateKey: "player.fame.set", PlayerID: "76561198000000001", Payload: map[string]any{"fame": 123}, Reason: "restore fame", IdempotencyKey: "fame-restore-1"} + operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, request) + if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting { + t.Fatalf("request operation=%+v err=%v", operation, err) + } + duplicate, err := svc.RequestSCUMOperationForSession(session, instance.ID, request) + if err != nil || duplicate.ID != operation.ID { + t.Fatalf("duplicate should return original operation: duplicate=%+v err=%v", duplicate, err) + } + approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID) + if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" { + t.Fatalf("approve operation=%+v err=%v", approved, err) + } + job, err := svc.store.Jobs().Get(approved.RunJobID) + if err != nil { + t.Fatalf("get operation job: %v", err) + } + serializedOperation, _ := json.Marshal(approved) + serializedJob, _ := json.Marshal(job) + for _, forbidden := range []string{"#SetFamePoints", "SetCurrencyBalance", "password="} { + if strings.Contains(string(serializedOperation), forbidden) || strings.Contains(string(serializedJob), forbidden) { + t.Fatalf("operation/job persisted raw RCON text %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob) + } + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { + t.Fatalf("claim operation RCON job: claim=%+v err=%v", claim, err) + } + ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) + if err != nil || !ack.Accepted { + t.Fatalf("ack operation RCON job: ack=%+v err=%v", ack, err) + } + input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}) + if err != nil { + t.Fatalf("read transient operation command: %v", err) + } + if input.Command != "#SetFamePoints 123 \"76561198000000001\"" { + t.Fatalf("unexpected generated RCON command: %q", input.Command) + } + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.succeeded", AuditSummary: "typed RCON delivered"}}); err != nil { + t.Fatalf("complete operation job: %v", err) + } + reconciled, err := svc.ReconcileSCUMOperation(approved.ID) + if err != nil || reconciled.Status != domain.SCUMWorkflowStepConfirming { + t.Fatalf("expected confirming after delivery before readback: %+v err=%v", reconciled, err) + } + confirmed, err := svc.ConfirmSCUMOperation(approved.ID, domain.SCUMOperationConfirmation{Status: "confirmed", ConfirmedFields: map[string]any{"fame": 123}, ObservedAt: fixedTime.Add(time.Minute)}) + if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.CompletedAt.IsZero() { + t.Fatalf("confirm operation=%+v err=%v", confirmed, err) + } +} + +func TestSCUMRCONOperationPermissionUnknownAndConfirmationFailure(t *testing.T) { + svc, session, runSession, instance := newSourceRCONFixture(t) + seedSCUMOperationTemplates(t, svc, instance.PluginID) + adminOnly, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.gold.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 9}, Reason: "admin-only", IdempotencyKey: "gold-admin-only"}) + if err != nil { + t.Fatalf("request admin-only operation: %v", err) + } + if _, err := svc.ApproveSCUMOperationForSession(session, adminOnly.ID); err != ErrForbidden { + t.Fatalf("expected platform-admin approval denial, got %v", err) + } + operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.normal.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 500}, Reason: "repair balance", IdempotencyKey: "normal-unknown"}) + if err != nil { + t.Fatalf("request normal currency operation: %v", err) + } + approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID) + if err != nil { + t.Fatalf("approve normal currency operation: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { + t.Fatalf("claim normal currency job: claim=%+v err=%v", claim, err) + } + ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) + if err != nil { + t.Fatalf("ack normal currency job: %v", err) + } + if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}); err != nil { + t.Fatalf("consume normal currency command: %v", err) + } + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.unknown", AuditSummary: "unknown command state"}}); err != nil { + t.Fatalf("complete unknown operation job: %v", err) + } + unknown, err := svc.ReconcileSCUMOperation(approved.ID) + if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown { + t.Fatalf("expected unknown terminal state: %+v err=%v", unknown, err) + } + failure, err := svc.ConfirmSCUMOperation(operation.ID, domain.SCUMOperationConfirmation{Status: "failed", SafeSummary: domain.SCUMSafeSummary{Title: "Readback mismatch", Message: "Projection did not match expected currency."}, ObservedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)}) + if err != nil || failure.Status != domain.SCUMWorkflowStepFailed { + t.Fatalf("expected confirmation failure: %+v err=%v", failure, err) + } +} + +func TestSCUMSQLiteMutationOperationSafetyGatesAndDispatchesTypedJob(t *testing.T) { + svc, session, runSession, instance := newSourceRCONFixture(t) + seedSCUMOperationTemplates(t, svc, instance.PluginID) + adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) + if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-online", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": true, "855": 100}}}); err != nil { + t.Fatalf("seed online projection: %v", err) + } + operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "76561198000000855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 150, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/20260810"}, Reason: "repair attribute 855", IdempotencyKey: "attribute-855-1"}) + if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting { + t.Fatalf("request sqlite mutation=%+v err=%v", operation, err) + } + waiting, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) + if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "离线") { + t.Fatalf("online player should block dispatch: %+v err=%v", waiting, err) + } + if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:profile-offline", ObservedAt: fixedTime.Add(time.Minute), Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": false, "855": 100}}}); err != nil { + t.Fatalf("seed offline projection: %v", err) + } + approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) + if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" { + t.Fatalf("approve sqlite mutation=%+v err=%v", approved, err) + } + job, err := svc.store.Jobs().Get(approved.RunJobID) + if err != nil { + t.Fatalf("get sqlite mutation job: %v", err) + } + if job.Capability != domain.JobCapabilityRemoteRunProtectedSQL || job.ExecutionInput.Inputs["fieldKey"] != "855" || job.ExecutionInput.Inputs["before"] != "100" || job.ExecutionInput.Inputs["after"] != "150" || job.ExecutionInput.Inputs["maxRowsAffected"] != "1" { + t.Fatalf("unexpected typed mutation job: %+v", job) + } + serializedOperation, _ := json.Marshal(approved) + serializedJob, _ := json.Marshal(job) + for _, forbidden := range []string{"UPDATE ", "DELETE ", "INSERT ", "SELECT ", "SCUM.db", "/Saved/", "requestText"} { + if strings.Contains(strings.ToUpper(string(serializedOperation)), strings.ToUpper(forbidden)) || strings.Contains(strings.ToUpper(string(serializedJob)), strings.ToUpper(forbidden)) { + t.Fatalf("operation/job persisted raw DB material %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob) + } + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { + t.Fatalf("claim sqlite mutation job: claim=%+v err=%v", claim, err) + } + ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) + if err != nil || !ack.Accepted { + t.Fatalf("ack sqlite mutation job: ack=%+v err=%v", ack, err) + } + mutationChecksum := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 1, "mutationChecksum": mutationChecksum, "confirmationRows": []map[string]any{{"playerId": "76561198000000855", "fieldKey": "855", "value": 150}}}) + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Checksum: mutationChecksum, AuditSummary: "typed SCUM DB mutation result", Content: content}}); err != nil { + t.Fatalf("complete sqlite mutation job: %v", err) + } + confirmed, err := svc.ReconcileSCUMOperation(approved.ID) + if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.Confirmation.AffectedRows != 1 || confirmed.Confirmation.MutationChecksum != mutationChecksum { + t.Fatalf("expected confirmed sqlite mutation: %+v err=%v", confirmed, err) + } +} + +func TestSCUMSQLiteMutationBlocksMissingSafetyAndStaleBefore(t *testing.T) { + svc, session, _, instance := newSourceRCONFixture(t) + seedSCUMOperationTemplates(t, svc, instance.PluginID) + adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) + if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-855", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-855", "displayName": "Guarded", "online": false, "855": 100}}}); err != nil { + t.Fatalf("seed projection: %v", err) + } + missingSafety, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 101}, Reason: "missing maintenance", IdempotencyKey: "attribute-855-missing-safety"}) + if err != nil { + t.Fatalf("request missing safety mutation: %v", err) + } + waiting, err := svc.ApproveSCUMOperationForSession(adminSession, missingSafety.ID) + if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "维护") { + t.Fatalf("expected missing maintenance/backup wait: %+v err=%v", waiting, err) + } + stale, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 99, "after": 101, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/stale"}, Reason: "stale before", IdempotencyKey: "attribute-855-stale-before"}) + if err != nil { + t.Fatalf("request stale mutation: %v", err) + } + blocked, err := svc.ApproveSCUMOperationForSession(adminSession, stale.ID) + if err != nil || blocked.Status != domain.SCUMWorkflowStepBlocked || blocked.RunJobID != "" || !strings.Contains(blocked.SafeSummary.Title, "before") { + t.Fatalf("expected stale before block: %+v err=%v", blocked, err) + } +} + +func TestSCUMSQLiteMutationResultValidationRejectsOverBoundRows(t *testing.T) { + svc, session, runSession, instance := newSourceRCONFixture(t) + seedSCUMOperationTemplates(t, svc, instance.PluginID) + adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) + if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-overbound", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-overbound", "online": false, "855": 10}}}); err != nil { + t.Fatalf("seed projection: %v", err) + } + operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-overbound", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 11, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/overbound"}, Reason: "overbound test", IdempotencyKey: "attribute-855-overbound"}) + if err != nil { + t.Fatalf("request overbound mutation: %v", err) + } + approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) + if err != nil || approved.RunJobID == "" { + t.Fatalf("approve overbound mutation=%+v err=%v", approved, err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil { + t.Fatalf("claim overbound job: claim=%+v err=%v", claim, err) + } + ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) + if err != nil { + t.Fatalf("ack overbound job: %v", err) + } + content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 2, "mutationChecksum": "sha256:mutation-overbound"}) + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Content: content, AuditSummary: "typed SCUM DB mutation result"}}); err != nil { + t.Fatalf("complete overbound job: %v", err) + } + unknown, err := svc.ReconcileSCUMOperation(approved.ID) + if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown { + t.Fatalf("expected over-bound rows to become unknown: %+v err=%v", unknown, err) + } +} + +func seedSCUMOperationTemplates(t *testing.T, svc *CoreService, pluginID string) { + t.Helper() + plugin, err := svc.store.GamePlugins().Get(pluginID) + if err != nil { + t.Fatal(err) + } + plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command") + plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{ + {Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, + {Key: "player.currency.normal.set", Title: "Set player normal currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, + {Key: "player.currency.gold.set", Title: "Set player gold currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, + } + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update plugin operation templates: %v", err) + } +} + +func enableSCUMSQLiteMutationOperationSupport(t *testing.T, svc *CoreService, instance domain.ServerInstance) string { + t.Helper() + adminSession := createServiceUserAndLogin(t, svc, domain.User{ID: "platform-admin-scum", DisplayName: "SCUM Admin", Email: "scum-admin@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"}) + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + t.Fatal(err) + } + plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.maintenance") + plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL) + plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL) + plugin.RemoteAccess.DatabaseEngines = append(plugin.RemoteAccess.DatabaseEngines, "sqlite") + plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}) + plugin.GameClientBridge.OperationTemplates = append(plugin.GameClientBridge.OperationTemplates, domain.GameClientBridgeOperationTemplateDeclaration{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}) + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update SCUM DB mutation plugin: %v", err) + } + endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + t.Fatal(err) + } + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("update SCUM DB mutation endpoint: %v", err) + } + return adminSession +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal test JSON: %v", err) + } + return string(encoded) +} diff --git a/platform/service/scum_projections.go b/platform/service/scum_projections.go new file mode 100644 index 0000000..b85f308 --- /dev/null +++ b/platform/service/scum_projections.go @@ -0,0 +1,803 @@ +package service + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservationResult) (domain.SCUMDataObservation, error) { + result = domain.CopySCUMObservationResult(result) + if strings.TrimSpace(result.ServerInstanceID) == "" { + return domain.SCUMDataObservation{}, validationError("serverInstanceId is required") + } + instance, err := svc.store.ServerInstances().Get(result.ServerInstanceID) + if err != nil { + return domain.SCUMDataObservation{}, err + } + if strings.TrimSpace(result.PluginID) == "" { + result.PluginID = instance.PluginID + } + if result.PluginID != instance.PluginID { + return domain.SCUMDataObservation{}, validationError("pluginId must match server instance") + } + if strings.TrimSpace(result.QueryKey) == "" { + return domain.SCUMDataObservation{}, validationError("queryKey is required") + } + if result.ReceivedAt.IsZero() { + result.ReceivedAt = svc.now() + } + if result.ObservedAt.IsZero() { + result.ObservedAt = result.ReceivedAt + } + if result.Status == "" { + result.Status = domain.SCUMObservationAccepted + } + latest, err := svc.latestSCUMObservation(result.ServerInstanceID, result.PluginID, result.QueryKey) + if err != nil { + return domain.SCUMDataObservation{}, err + } + if result.Status == domain.SCUMObservationAccepted && !latest.ObservedAt.IsZero() && scumObservationOlder(result, latest) { + result.Status = domain.SCUMObservationStale + result.ErrorCode = "older_observation" + result.SafeSummary = domain.SCUMSafeSummary{Title: "旧观察已忽略", Message: "Run 返回的 SCUM.db 观察早于当前本地投影,未覆盖 last-known-good 数据。"} + } + observation := domain.SCUMDataObservation{ID: scumObservationID(result), ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, Status: result.Status, ErrorCode: result.ErrorCode, SafeSummary: result.SafeSummary, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt} + if err := svc.upsertSCUMObservation(observation); err != nil { + return domain.SCUMDataObservation{}, err + } + if result.Status != domain.SCUMObservationAccepted { + if result.Status == domain.SCUMObservationFailed { + return observation, svc.markSCUMQueryStale(result, "observation_failed") + } + return observation, nil + } + freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt} + if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil { + return domain.SCUMDataObservation{}, err + } + return observation, nil +} + +func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMPlayerLiveStates().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) ListSCUMSquadsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMSquads().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) ListSCUMSquadMembersForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMSquadMembers().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMVehicles().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) ListSCUMFlagsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMFlags().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) ListSCUMCurrentPositionsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMCurrentPositions().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) latestSCUMObservation(serverID, pluginID, queryKey string) (domain.SCUMDataObservation, error) { + observations, err := svc.store.SCUMDataObservations().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, QueryKey: queryKey}) + if err != nil { + return domain.SCUMDataObservation{}, err + } + var latest domain.SCUMDataObservation + for _, observation := range observations { + if pluginID != "" && observation.PluginID != pluginID { + continue + } + if latest.ObservedAt.IsZero() || observation.Sequence > latest.Sequence || (observation.Sequence == latest.Sequence && observation.ObservedAt.After(latest.ObservedAt)) { + latest = observation + } + } + return latest, nil +} + +func scumObservationOlder(next domain.SCUMObservationResult, latest domain.SCUMDataObservation) bool { + if next.Sequence > 0 && latest.Sequence > 0 && next.Sequence <= latest.Sequence { + return true + } + return !next.ObservedAt.IsZero() && !latest.ObservedAt.IsZero() && next.ObservedAt.Before(latest.ObservedAt) +} + +func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObservation) error { + if existing, err := svc.store.SCUMDataObservations().Get(observation.ID); err == nil { + existing.Status = observation.Status + existing.ErrorCode = observation.ErrorCode + existing.SafeSummary = observation.SafeSummary + existing.ReceivedAt = observation.ReceivedAt + return svc.store.SCUMDataObservations().Update(existing) + } else if err != repo.ErrNotFound { + return err + } + return svc.store.SCUMDataObservations().Create(observation) +} + +func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error { + lower := strings.ToLower(queryKey) + if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") { + for _, row := range rows { + if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil { + return err + } + } + } + if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") { + for _, row := range rows { + if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil { + return err + } + } + } else if strings.Contains(lower, "squad") { + for _, row := range rows { + if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil { + return err + } + } + } + if strings.Contains(lower, "vehicle") { + for _, row := range rows { + if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil { + return err + } + } + } + if strings.Contains(lower, "flag") { + for _, row := range rows { + if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil { + return err + } + } + } + if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") { + for _, row := range rows { + if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil { + return err + } + } + } + return nil +} + +func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { + gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") + profileID := firstString(row, "userProfileId", "user_profile_id", "profileId") + steamID := firstString(row, "steamId", "steam_id") + name := firstString(row, "displayName", "name", "playerName") + if gamePlayerID == "" && steamID != "" { + gamePlayerID = steamID + } + if gamePlayerID == "" && profileID == "" { + return nil + } + playerRecordID := "" + if gamePlayerID != "" { + playerRecordID = gamePlayerRecordID(serverID, gamePlayerID) + if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, name, freshness.ObservedAt); err != nil { + return err + } + } + idSource := gamePlayerID + if idSource == "" { + idSource = "profile-" + profileID + } + id := scumProjectionID("player-live", serverID, idSource) + state, err := svc.store.SCUMPlayerLiveStates().Get(id) + if err == repo.ErrNotFound { + state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, UserProfileID: profileID, SteamID: steamID, DisplayName: name, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} + } else if err != nil { + return err + } + if isProjectionOlder(freshness, state.Freshness) { + return nil + } + state.GamePlayerRecordID = coalesceString(playerRecordID, state.GamePlayerRecordID) + state.GamePlayerID = coalesceString(gamePlayerID, state.GamePlayerID) + state.UserProfileID = coalesceString(profileID, state.UserProfileID) + state.SteamID = coalesceString(steamID, state.SteamID) + state.DisplayName = coalesceString(name, state.DisplayName) + state.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), state.SquadID) + state.SquadName = coalesceString(firstString(row, "squadName", "squad_name"), state.SquadName) + if value, ok := firstFloat(row, "famePoints", "fame_points", "fame"); ok { + state.FamePoints = value + } + if value, ok := firstFloat(row, "normalBalance", "currencyNormal", "money", "normal_balance"); ok { + state.NormalBalance = value + } + if value, ok := firstFloat(row, "goldBalance", "currencyGold", "gold", "gold_balance"); ok { + state.GoldBalance = value + } + if value, ok := firstBool(row, "online", "isOnline"); ok { + state.Online = value + } + state.LastLoginAt = coalesceTime(firstTime(row, "lastLoginAt", "last_login_at"), state.LastLoginAt) + state.LastLogoutAt = coalesceTime(firstTime(row, "lastLogoutAt", "last_logout_at"), state.LastLogoutAt) + state.LastSaveTime = coalesceTime(firstTime(row, "lastSaveTime", "last_save_time"), state.LastSaveTime) + if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectPlayer, gamePlayerID, row, freshness); ok { + position.GamePlayerRecordID = playerRecordID + position.GamePlayerID = gamePlayerID + state.Position = position + if err := svc.upsertSCUMPosition(position); err != nil { + return err + } + } + state.UnknownFields = unknownRowFields(row, "gamePlayerId", "playerId", "steamId", "steam_id", "userProfileId", "user_profile_id", "profileId", "displayName", "name", "playerName", "squadId", "squad_id", "squadName", "squad_name", "famePoints", "fame_points", "fame", "normalBalance", "currencyNormal", "money", "normal_balance", "goldBalance", "currencyGold", "gold", "gold_balance", "online", "isOnline", "lastLoginAt", "last_login_at", "lastLogoutAt", "last_logout_at", "lastSaveTime", "last_save_time", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") + state.Freshness = freshness + state.UpdatedAt = svc.now() + if err == repo.ErrNotFound { + return svc.store.SCUMPlayerLiveStates().Create(state) + } + return svc.store.SCUMPlayerLiveStates().Update(state) +} + +func (svc *CoreService) applySCUMSquadRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { + squadID := firstString(row, "squadId", "squad_id", "id") + if squadID == "" { + return nil + } + id := scumProjectionID("squad", serverID, squadID) + value, err := svc.store.SCUMSquads().Get(id) + if err == repo.ErrNotFound { + value = domain.SCUMSquad{ID: id, ServerInstanceID: serverID, SquadID: squadID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} + } else if err != nil { + return err + } + if isProjectionOlder(freshness, value.Freshness) { + return nil + } + value.Name = coalesceString(firstString(row, "name", "squadName", "squad_name"), value.Name) + value.LeaderProfileID = coalesceString(firstString(row, "leaderProfileId", "leader_profile_id"), value.LeaderProfileID) + value.LeaderPlayerID = coalesceString(firstString(row, "leaderPlayerId", "leader_player_id", "leaderSteamId"), value.LeaderPlayerID) + if memberCount, ok := firstInt(row, "memberCount", "member_count"); ok { + value.MemberCount = memberCount + } + if score, ok := firstFloat(row, "score", "fame", "points"); ok { + value.Score = score + } + value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "id", "name", "squadName", "squad_name", "leaderProfileId", "leader_profile_id", "leaderPlayerId", "leader_player_id", "leaderSteamId", "memberCount", "member_count", "score", "fame", "points") + value.Freshness = freshness + value.UpdatedAt = svc.now() + if err == repo.ErrNotFound { + return svc.store.SCUMSquads().Create(value) + } + return svc.store.SCUMSquads().Update(value) +} + +func (svc *CoreService) applySCUMSquadMemberRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { + squadID := firstString(row, "squadId", "squad_id") + profileID := firstString(row, "userProfileId", "user_profile_id", "profileId") + gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") + if squadID == "" || (profileID == "" && gamePlayerID == "") { + return nil + } + playerRecordID := "" + if gamePlayerID != "" { + playerRecordID = gamePlayerRecordID(serverID, gamePlayerID) + if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, firstString(row, "displayName", "name", "playerName"), freshness.ObservedAt); err != nil { + return err + } + } + id := scumProjectionID("squad-member", serverID, squadID+"/"+coalesceString(profileID, gamePlayerID)) + value, err := svc.store.SCUMSquadMembers().Get(id) + if err == repo.ErrNotFound { + value = domain.SCUMSquadMember{ID: id, ServerInstanceID: serverID, SquadID: squadID, UserProfileID: profileID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} + } else if err != nil { + return err + } + if isProjectionOlder(freshness, value.Freshness) { + return nil + } + value.UserProfileID = coalesceString(profileID, value.UserProfileID) + value.GamePlayerRecordID = coalesceString(playerRecordID, value.GamePlayerRecordID) + value.GamePlayerID = coalesceString(gamePlayerID, value.GamePlayerID) + value.SteamID = coalesceString(firstString(row, "steamId", "steam_id"), value.SteamID) + value.DisplayName = coalesceString(firstString(row, "displayName", "name", "playerName"), value.DisplayName) + value.Rank = coalesceString(firstString(row, "rank", "role"), value.Rank) + if isLeader, ok := firstBool(row, "isLeader", "leader"); ok { + value.IsLeader = isLeader + } + value.JoinedAt = coalesceTime(firstTime(row, "joinedAt", "joined_at"), value.JoinedAt) + value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "userProfileId", "user_profile_id", "profileId", "gamePlayerId", "playerId", "steamId", "steam_id", "displayName", "name", "playerName", "rank", "role", "isLeader", "leader", "joinedAt", "joined_at") + value.Freshness = freshness + value.UpdatedAt = svc.now() + if err == repo.ErrNotFound { + return svc.store.SCUMSquadMembers().Create(value) + } + return svc.store.SCUMSquadMembers().Update(value) +} + +func (svc *CoreService) applySCUMVehicleRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { + vehicleID := firstString(row, "vehicleId", "vehicle_id", "id") + entityID := firstString(row, "entityId", "entity_id") + if vehicleID == "" && entityID != "" { + vehicleID = entityID + } + if vehicleID == "" { + return nil + } + id := scumProjectionID("vehicle", serverID, vehicleID) + value, err := svc.store.SCUMVehicles().Get(id) + if err == repo.ErrNotFound { + value = domain.SCUMVehicle{ID: id, ServerInstanceID: serverID, VehicleID: vehicleID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} + } else if err != nil { + return err + } + if isProjectionOlder(freshness, value.Freshness) { + return nil + } + value.EntityID = coalesceString(entityID, value.EntityID) + value.ClassName = coalesceString(firstString(row, "className", "class", "type"), value.ClassName) + value.Label = coalesceString(firstString(row, "label", "vehicleName", "name"), value.Label) + if value.Label == "" { + value.Label = coalesceString(value.ClassName, "Unknown vehicle") + } + value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID) + value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID) + value.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), value.SquadID) + if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectVehicle, vehicleID, row, freshness); ok { + position.VehicleID = vehicleID + position.EntityID = entityID + value.Position = position + if err := svc.upsertSCUMPosition(position); err != nil { + return err + } + } + value.UnknownFields = unknownRowFields(row, "vehicleId", "vehicle_id", "id", "entityId", "entity_id", "className", "class", "type", "label", "vehicleName", "name", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "squadId", "squad_id", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") + value.Freshness = freshness + value.UpdatedAt = svc.now() + if err == repo.ErrNotFound { + return svc.store.SCUMVehicles().Create(value) + } + return svc.store.SCUMVehicles().Update(value) +} + +func (svc *CoreService) applySCUMFlagRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { + flagID := firstString(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id") + entityID := firstString(row, "entityId", "entity_id") + if flagID == "" && entityID != "" { + flagID = entityID + } + if flagID == "" { + return nil + } + id := scumProjectionID("flag", serverID, flagID) + value, err := svc.store.SCUMFlags().Get(id) + if err == repo.ErrNotFound { + value = domain.SCUMFlag{ID: id, ServerInstanceID: serverID, FlagID: flagID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} + } else if err != nil { + return err + } + if isProjectionOlder(freshness, value.Freshness) { + return nil + } + value.EntityID = coalesceString(entityID, value.EntityID) + value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID) + value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID) + value.OwnerSquadID = coalesceString(firstString(row, "ownerSquadId", "owner_squad_id", "squadId", "squad_id"), value.OwnerSquadID) + value.OwnerSquadName = coalesceString(firstString(row, "ownerSquadName", "owner_squad_name", "squadName", "squad_name"), value.OwnerSquadName) + value.OwnershipConfidence = coalesceString(firstString(row, "ownershipConfidence", "ownership_confidence"), value.OwnershipConfidence) + if value.OwnershipConfidence == "" { + value.OwnershipConfidence = "unknown" + } + if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectFlag, flagID, row, freshness); ok { + position.EntityID = entityID + value.Position = position + if err := svc.upsertSCUMPosition(position); err != nil { + return err + } + } + value.UnknownFields = unknownRowFields(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id", "entityId", "entity_id", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "ownerSquadId", "owner_squad_id", "squadId", "squad_id", "ownerSquadName", "owner_squad_name", "squadName", "squad_name", "ownershipConfidence", "ownership_confidence", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") + value.Freshness = freshness + value.UpdatedAt = svc.now() + if err == repo.ErrNotFound { + return svc.store.SCUMFlags().Create(value) + } + return svc.store.SCUMFlags().Update(value) +} + +func (svc *CoreService) applySCUMPositionRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { + subjectType := domain.SCUMProjectionSubject(firstString(row, "subjectType", "subject_type")) + if subjectType == "" { + if firstString(row, "vehicleId", "vehicle_id") != "" { + subjectType = domain.SCUMProjectionSubjectVehicle + } else { + subjectType = domain.SCUMProjectionSubjectPlayer + } + } + subjectID := firstString(row, "subjectId", "subject_id", "gamePlayerId", "playerId", "vehicleId", "flagId", "entityId", "id") + position, ok := scumPositionFromRow(serverID, subjectType, subjectID, row, freshness) + if !ok { + return nil + } + position.GamePlayerID = firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") + if position.GamePlayerID != "" { + position.GamePlayerRecordID = gamePlayerRecordID(serverID, position.GamePlayerID) + } + position.VehicleID = firstString(row, "vehicleId", "vehicle_id") + position.EntityID = firstString(row, "entityId", "entity_id") + return svc.upsertSCUMPosition(position) +} + +func (svc *CoreService) upsertSCUMGamePlayer(serverID, recordID, gamePlayerID, displayName string, observedAt time.Time) error { + if gamePlayerID == "" || recordID == "" { + return nil + } + if observedAt.IsZero() { + observedAt = svc.now() + } + player, err := svc.store.GamePlayers().Get(recordID) + if err == repo.ErrNotFound { + return svc.store.GamePlayers().Create(domain.GamePlayer{ID: recordID, ServerInstanceID: serverID, GamePlayerID: gamePlayerID, DisplayName: displayName, FirstSeenAt: observedAt, LastSeenAt: observedAt, LastEventAt: observedAt, CreatedAt: svc.now(), UpdatedAt: svc.now()}) + } + if err != nil { + return err + } + if observedAt.Before(player.LastEventAt) { + return nil + } + player.DisplayName = coalesceString(displayName, player.DisplayName) + player.LastSeenAt = maxTime(player.LastSeenAt, observedAt) + player.LastEventAt = observedAt + player.UpdatedAt = svc.now() + return svc.store.GamePlayers().Update(player) +} + +func (svc *CoreService) projectSCUMLoginLiveState(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, observedAt time.Time, online bool, reason string) error { + if player.ID == "" || player.GamePlayerID == "" { + return nil + } + freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: entryID(batch.LogStreamID, entry.Seq), Source: "login-log", QueryKey: strings.TrimSpace(entry.Fields["eventType"]), Sequence: entry.Seq, Checksum: validator.LogLineChecksum(entry.Line), ObservedAt: observedAt, ReceivedAt: svc.now()} + id := scumProjectionID("player-live", player.ServerInstanceID, player.GamePlayerID) + state, err := svc.store.SCUMPlayerLiveStates().Get(id) + if err == repo.ErrNotFound { + state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, DisplayName: player.DisplayName, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} + } else if err != nil { + return err + } + if isProjectionOlder(freshness, state.Freshness) { + return nil + } + state.GamePlayerRecordID = player.ID + state.GamePlayerID = player.GamePlayerID + state.DisplayName = player.DisplayName + state.Online = online + if online { + state.LastLoginAt = observedAt + } else { + state.LastLogoutAt = observedAt + } + state.Freshness = freshness + if reason != "" { + state.UnknownFields = domain.CopyGameClientBridgePayload(map[string]any{"lastLogoutReason": bounded(reason, 80)}) + } + state.UpdatedAt = svc.now() + if err == repo.ErrNotFound { + return svc.store.SCUMPlayerLiveStates().Create(state) + } + return svc.store.SCUMPlayerLiveStates().Update(state) +} + +func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition) error { + existing, err := svc.store.SCUMCurrentPositions().Get(position.ID) + if err == repo.ErrNotFound { + position.CreatedAt = svc.now() + position.UpdatedAt = svc.now() + return svc.store.SCUMCurrentPositions().Create(position) + } + if err != nil { + return err + } + if isProjectionOlder(position.Freshness, existing.Freshness) { + return nil + } + position.CreatedAt = existing.CreatedAt + position.UpdatedAt = svc.now() + return svc.store.SCUMCurrentPositions().Update(position) +} + +func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error { + freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt} + lower := strings.ToLower(result.QueryKey) + if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") { + values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) + if err != nil { + return err + } + for _, value := range values { + if !isProjectionOlder(freshness, value.Freshness) { + value.Freshness = freshness + value.UpdatedAt = svc.now() + if err := svc.store.SCUMPlayerLiveStates().Update(value); err != nil { + return err + } + } + } + } + if strings.Contains(lower, "squad") { + values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) + if err != nil { + return err + } + for _, value := range values { + if !isProjectionOlder(freshness, value.Freshness) { + value.Freshness = freshness + value.UpdatedAt = svc.now() + if err := svc.store.SCUMSquads().Update(value); err != nil { + return err + } + } + } + } + if strings.Contains(lower, "vehicle") { + values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) + if err != nil { + return err + } + for _, value := range values { + if !isProjectionOlder(freshness, value.Freshness) { + value.Freshness = freshness + value.UpdatedAt = svc.now() + if err := svc.store.SCUMVehicles().Update(value); err != nil { + return err + } + } + } + } + if strings.Contains(lower, "flag") { + values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) + if err != nil { + return err + } + for _, value := range values { + if !isProjectionOlder(freshness, value.Freshness) { + value.Freshness = freshness + value.UpdatedAt = svc.now() + if err := svc.store.SCUMFlags().Update(value); err != nil { + return err + } + } + } + } + return nil +} + +func scumPositionFromRow(serverID string, subjectType domain.SCUMProjectionSubject, subjectID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) (domain.SCUMCurrentPosition, bool) { + x, hasX := firstFloat(row, "x", "worldX", "world_x", "locationX") + y, hasY := firstFloat(row, "y", "worldY", "world_y", "locationY") + z, hasZ := firstFloat(row, "z", "worldZ", "world_z", "locationZ") + if !hasX || !hasY { + return domain.SCUMCurrentPosition{}, false + } + if subjectID == "" { + return domain.SCUMCurrentPosition{}, false + } + position := domain.SCUMCurrentPosition{ID: scumProjectionID("position-"+string(subjectType), serverID, subjectID), ServerInstanceID: serverID, SubjectType: subjectType, SubjectID: subjectID, MapID: coalesceString(firstString(row, "mapId", "map_id"), domain.SCUMMapTrajectoryMapID), MapVersion: coalesceString(firstString(row, "mapVersion", "map_version"), "0.9"), X: x, Y: y, HasCoordinates: true, LastSaveTime: firstTime(row, "lastSaveTime", "last_save_time"), Freshness: freshness} + if hasZ && !math.IsNaN(z) { + position.Z = z + } + return position, true +} + +func isProjectionOlder(next, current domain.SCUMProjectionFreshnessState) bool { + if current.Status == "" || current.Status == domain.SCUMProjectionUnknown { + return false + } + if next.Source == current.Source && next.QueryKey == current.QueryKey && next.Sequence > 0 && current.Sequence > 0 && next.Sequence < current.Sequence { + return true + } + return !next.ObservedAt.IsZero() && !current.ObservedAt.IsZero() && next.ObservedAt.Before(current.ObservedAt) +} + +func scumObservationID(result domain.SCUMObservationResult) string { + seed := fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.Checksum) + if result.Checksum == "" { + seed = fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.ObservedAt.Format(time.RFC3339Nano)) + } + return "scum-observation-" + fingerprintID(result.ServerInstanceID, seed) +} + +func scumProjectionID(kind, serverID, subject string) string { + return "scum-" + kind + "-" + fingerprintID(serverID, subject) +} + +func firstString(row map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := row[key]; ok { + switch typed := value.(type) { + case string: + if trimmed := strings.TrimSpace(typed); trimmed != "" { + return trimmed + } + case fmt.Stringer: + if trimmed := strings.TrimSpace(typed.String()); trimmed != "" { + return trimmed + } + case int, int64, uint64, float64: + return fmt.Sprint(typed) + } + } + } + return "" +} + +func firstFloat(row map[string]any, keys ...string) (float64, bool) { + for _, key := range keys { + if value, ok := row[key]; ok { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case uint64: + return float64(typed), true + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + if err == nil { + return parsed, true + } + } + } + } + return 0, false +} + +func firstInt(row map[string]any, keys ...string) (int, bool) { + value, ok := firstFloat(row, keys...) + if !ok { + return 0, false + } + return int(value), true +} + +func firstBool(row map[string]any, keys ...string) (bool, bool) { + for _, key := range keys { + if value, ok := row[key]; ok { + switch typed := value.(type) { + case bool: + return typed, true + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(typed)) + if err == nil { + return parsed, true + } + case int: + return typed != 0, true + case int64: + return typed != 0, true + case float64: + return typed != 0, true + } + } + } + return false, false +} + +func firstTime(row map[string]any, keys ...string) time.Time { + for _, key := range keys { + if value, ok := row[key]; ok { + switch typed := value.(type) { + case time.Time: + return typed + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + continue + } + if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil { + return parsed + } + if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil { + return parsed.UTC() + } + case int64: + return time.Unix(typed, 0).UTC() + case float64: + return time.Unix(int64(typed), 0).UTC() + } + } + } + return time.Time{} +} + +func unknownRowFields(row map[string]any, known ...string) map[string]any { + knownSet := map[string]struct{}{} + for _, key := range known { + knownSet[key] = struct{}{} + } + unknown := map[string]any{} + for key, value := range row { + if _, ok := knownSet[key]; ok { + continue + } + unknown[key] = value + } + if len(unknown) == 0 { + return nil + } + return domain.CopyGameClientBridgePayload(unknown) +} + +func coalesceString(next, current string) string { + if strings.TrimSpace(next) != "" { + return strings.TrimSpace(next) + } + return current +} + +func coalesceTime(next, current time.Time) time.Time { + if !next.IsZero() { + return next + } + return current +} + +func limitSCUMProjectionSlice[T any](values *[]T, limit int) { + if limit > 0 && len(*values) > limit { + *values = (*values)[:limit] + } +} diff --git a/platform/service/scum_projections_test.go b/platform/service/scum_projections_test.go new file mode 100644 index 0000000..7b510be --- /dev/null +++ b/platform/service/scum_projections_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "testing" + "time" + + "browser.local/platform/domain" +) + +func TestSCUMObservationProjectsRealRowsAndSeparatesProfileFromSteamID(t *testing.T) { + svc, _ := newRegisteredLogIngestService(t) + observed := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + observation, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ + ServerInstanceID: "server-1", + PluginID: "server.scum", + Source: "run.sqlite.read", + QueryKey: "scum.player.profile", + Sequence: 10, + Checksum: "sha256:profile-10", + ObservedAt: observed, + Rows: []map[string]any{{ + "gamePlayerId": "steam-1", + "userProfileId": "profile-99", + "steamId": "steam-1", + "displayName": "Moon", + "squadId": "squad-1", + "famePoints": 42, + "normalBalance": 500.0, + "goldBalance": 7.0, + "x": 100, + "y": 200, + "z": 30, + "lastSaveTime": observed.Add(-time.Minute).Format(time.RFC3339), + "future_column": "preserved", + }}, + }) + if err != nil || observation.Status != domain.SCUMObservationAccepted { + t.Fatalf("apply observation=%+v err=%v", observation, err) + } + player, err := svc.store.GamePlayers().Get(gamePlayerRecordID("server-1", "steam-1")) + if err != nil || player.DisplayName != "Moon" { + t.Fatalf("expected game player from real row: player=%+v err=%v", player, err) + } + states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-99"}) + if err != nil || len(states) != 1 { + t.Fatalf("states=%+v err=%v", states, err) + } + state := states[0] + if state.GamePlayerID != "steam-1" || state.UserProfileID != "profile-99" || state.SteamID != "steam-1" || state.NormalBalance != 500 || state.Online { + t.Fatalf("identity/economy projection mixed IDs or inferred online incorrectly: %+v", state) + } + if !state.Position.HasCoordinates || state.Position.X != 100 || state.Position.Y != 200 || state.UnknownFields["future_column"] != "preserved" { + t.Fatalf("position/unknown fields not projected safely: %+v", state) + } + stale, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 9, Checksum: "sha256:profile-9", ObservedAt: observed.Add(-time.Hour), Rows: []map[string]any{{"gamePlayerId": "steam-1", "userProfileId": "profile-99", "displayName": "Old", "normalBalance": 9999}}}) + if err != nil || stale.Status != domain.SCUMObservationStale || stale.ErrorCode != "older_observation" { + t.Fatalf("expected older observation stale, got %+v err=%v", stale, err) + } + again, err := svc.store.SCUMPlayerLiveStates().Get(state.ID) + if err != nil || again.DisplayName != "Moon" || again.NormalBalance != 500 { + t.Fatalf("older observation overwrote last-known-good: %+v err=%v", again, err) + } +} + +func TestSCUMFailedObservationMarksStaleWithoutOverwritingProjection(t *testing.T) { + svc, _ := newRegisteredLogIngestService(t) + observed := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC) + if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:ok", ObservedAt: observed, Rows: []map[string]any{{"gamePlayerId": "steam-2", "userProfileId": "profile-2", "displayName": "Nova", "normalBalance": 125}}}); err != nil { + t.Fatalf("apply initial observation: %v", err) + } + failed, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:failed", Status: domain.SCUMObservationFailed, ErrorCode: "sqlite_busy", ObservedAt: observed.Add(time.Minute)}) + if err != nil || failed.Status != domain.SCUMObservationFailed { + t.Fatalf("failed observation=%+v err=%v", failed, err) + } + states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-2"}) + if err != nil || len(states) != 1 { + t.Fatalf("states=%+v err=%v", states, err) + } + if states[0].NormalBalance != 125 || states[0].Freshness.Status != domain.SCUMProjectionStale || states[0].Freshness.StaleReason != "observation_failed" { + t.Fatalf("failed query did not preserve values and mark stale: %+v", states[0]) + } +} + +func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *testing.T) { + svc, token := newRegisteredLogIngestService(t) + createLogStreamFixture(t, svc) + base := time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC) + login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login accepted", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "outcome": "accepted"}}}) + if _, err := svc.IngestLogBatch(login); err != nil { + t.Fatalf("ingest login: %v", err) + } + states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"}) + if err != nil || len(states) != 1 || !states[0].Online { + t.Fatalf("login did not mark live state online: states=%+v err=%v", states, err) + } + logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "reason": "disconnect"}}}) + if _, err := svc.IngestLogBatch(logout); err != nil { + t.Fatalf("ingest logout: %v", err) + } + if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 3, Checksum: "sha256:save-time", ObservedAt: base.Add(2 * time.Minute), Rows: []map[string]any{{"gamePlayerId": "steam-3", "userProfileId": "profile-3", "displayName": "Comet", "lastSaveTime": base.Add(90 * time.Second).Format(time.RFC3339)}}}); err != nil { + t.Fatalf("apply save-time observation: %v", err) + } + states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"}) + if err != nil || len(states) != 1 { + t.Fatalf("states=%+v err=%v", states, err) + } + if states[0].Online || states[0].LastSaveTime.IsZero() { + t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0]) + } +} diff --git a/platform/service/scum_workflows.go b/platform/service/scum_workflows.go new file mode 100644 index 0000000..78ec9e5 --- /dev/null +++ b/platform/service/scum_workflows.go @@ -0,0 +1,393 @@ +package service + +import ( + "fmt" + "sort" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +type scumWorkflowTemplateDefinition struct { + Key string + Title string + Steps []scumWorkflowStepDefinition +} + +type scumWorkflowStepDefinition struct { + Key string + DependsOn []string + OperationKey string + QueryTemplateKey string + Capability string + TargetKey string + MutatesState bool + MaxAttempts int + Summary string +} + +func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string, request domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) { + request = domain.CopySCUMWorkflowInstance(request) + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.SCUMWorkflowInstance{}, err + } + if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { + return domain.SCUMWorkflowInstance{}, err + } + instance, err := svc.store.ServerInstances().Get(serverID) + if err != nil { + return domain.SCUMWorkflowInstance{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.SCUMWorkflowInstance{}, err + } + template, ok := scumWorkflowTemplates()[request.TemplateKey] + if !ok { + return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow template is not declared") + } + if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { + return domain.SCUMWorkflowInstance{}, validationError("workflow idempotency key is required") + } + if existing, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}); err == nil && len(existing) > 0 { + return domain.CopySCUMWorkflowInstance(existing[0]), nil + } else if err != nil { + return domain.SCUMWorkflowInstance{}, err + } + stamp := svc.now() + workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM workflow queued with typed steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp} + if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil { + return domain.SCUMWorkflowInstance{}, err + } + for index, step := range template.Steps { + maxAttempts := step.MaxAttempts + if maxAttempts == 0 { + maxAttempts = 1 + } + record := domain.SCUMWorkflowStep{ID: fmt.Sprintf("%s.step.%02d.%s", workflow.ID, index+1, step.Key), WorkflowID: workflow.ID, ServerInstanceID: serverID, StepKey: step.Key, DependsOn: domain.CopyStringSlice(step.DependsOn), Status: domain.SCUMWorkflowStepQueued, OperationKey: step.OperationKey, QueryTemplateKey: step.QueryTemplateKey, Capability: step.Capability, TargetKey: step.TargetKey, MaxAttempts: maxAttempts, MutatesState: step.MutatesState, SafeSummary: domain.SCUMSafeSummary{Title: step.Key, Message: step.Summary}, CreatedAt: stamp, UpdatedAt: stamp} + if err := svc.store.SCUMWorkflowSteps().Create(record); err != nil { + return domain.SCUMWorkflowInstance{}, err + } + } + _, err = svc.recordAuditEventWithID(user.ID, "scum.workflow.create", "scum-workflow", workflow.ID, domain.AuditResultQueued, "typed SCUM workflow queued") + return domain.CopySCUMWorkflowInstance(workflow), err +} + +func (svc *CoreService) ListSCUMWorkflowsForSession(sessionID string, filter domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMWorkflowInstances().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) ListSCUMWorkflowStepsForSession(sessionID string, filter domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.SCUMWorkflowSteps().List(filter) + if err != nil { + return nil, err + } + limitSCUMProjectionSlice(&values, filter.Limit) + return values, nil +} + +func (svc *CoreService) DispatchNextSCUMWorkflowSteps(serverID string, limit int) ([]domain.SCUMWorkflowStep, error) { + if limit <= 0 { + limit = 1 + } + workflows, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID}) + if err != nil { + return nil, err + } + sort.SliceStable(workflows, func(i, j int) bool { + if workflows[i].CreatedAt.Equal(workflows[j].CreatedAt) { + return workflows[i].IdempotencyKey < workflows[j].IdempotencyKey + } + return workflows[i].CreatedAt.Before(workflows[j].CreatedAt) + }) + dispatched := []domain.SCUMWorkflowStep{} + activeMutating, err := svc.hasActiveSCUMMutatingStep(serverID) + if err != nil { + return nil, err + } + for _, workflow := range workflows { + if !scumWorkflowRunnable(workflow.Status) || len(dispatched) >= limit { + continue + } + steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID) + if err != nil { + return nil, err + } + for _, step := range steps { + if len(dispatched) >= limit || !scumWorkflowStepRunnable(step.Status) || !scumWorkflowDependenciesConfirmed(step, steps) { + continue + } + if step.MutatesState && activeMutating { + return dispatched, nil + } + if blocked, err := svc.blockSCUMStepIfRunUnavailable(workflow, step); err != nil || blocked.ID != "" { + if err != nil { + return nil, err + } + dispatched = append(dispatched, blocked) + return dispatched, nil + } + step.Status = domain.SCUMWorkflowStepRunning + step.Attempt++ + step.UpdatedAt = svc.now() + if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { + return nil, err + } + workflow.Status = domain.SCUMWorkflowRunning + workflow.CurrentStepKey = step.StepKey + workflow.UpdatedAt = step.UpdatedAt + if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { + return nil, err + } + dispatched = append(dispatched, domain.CopySCUMWorkflowStep(step)) + if step.MutatesState { + activeMutating = true + return dispatched, nil + } + } + } + return dispatched, nil +} + +func (svc *CoreService) CompleteSCUMWorkflowStep(stepID string, status domain.SCUMWorkflowStepStatus, confirmation domain.SCUMOperationConfirmation) (domain.SCUMWorkflowInstance, error) { + step, err := svc.store.SCUMWorkflowSteps().Get(stepID) + if err != nil { + return domain.SCUMWorkflowInstance{}, err + } + workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID) + if err != nil { + return domain.SCUMWorkflowInstance{}, err + } + if !scumWorkflowStepTerminal(status) { + return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow step completion status must be terminal") + } + stamp := svc.now() + step.Status = status + step.Confirmation = domain.CopySCUMOperationConfirmation(confirmation) + step.CompletedAt = stamp + step.UpdatedAt = stamp + if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { + return domain.SCUMWorkflowInstance{}, err + } + return svc.refreshSCUMWorkflowStatus(workflow) +} + +func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflowStep, error) { + step, err := svc.store.SCUMWorkflowSteps().Get(stepID) + if err != nil { + return domain.SCUMWorkflowStep{}, err + } + workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID) + if err != nil { + return domain.SCUMWorkflowStep{}, err + } + if step.Attempt >= step.MaxAttempts { + return domain.SCUMWorkflowStep{}, validationError("SCUM workflow step retry limit reached") + } + if step.MutatesState && step.Status == domain.SCUMWorkflowStepUnknown && step.Confirmation.Status != "confirmed" { + step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; workflow must run confirmation/readback before retry to avoid duplicate effects."} + step.UpdatedAt = svc.now() + if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { + return domain.SCUMWorkflowStep{}, err + } + return domain.CopySCUMWorkflowStep(step), nil + } + step.Status = domain.SCUMWorkflowStepQueued + step.Confirmation = domain.SCUMOperationConfirmation{} + step.CompletedAt = time.Time{} + step.UpdatedAt = svc.now() + if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { + return domain.SCUMWorkflowStep{}, err + } + workflow.Status = domain.SCUMWorkflowQueued + workflow.BlockerReason = "" + workflow.UpdatedAt = step.UpdatedAt + if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { + return domain.SCUMWorkflowStep{}, err + } + return domain.CopySCUMWorkflowStep(step), nil +} + +func (svc *CoreService) sortedSCUMWorkflowSteps(workflowID string) ([]domain.SCUMWorkflowStep, error) { + steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{WorkflowID: workflowID}) + if err != nil { + return nil, err + } + sort.SliceStable(steps, func(i, j int) bool { + if steps[i].CreatedAt.Equal(steps[j].CreatedAt) { + return steps[i].ID < steps[j].ID + } + return steps[i].CreatedAt.Before(steps[j].CreatedAt) + }) + return steps, nil +} + +func (svc *CoreService) hasActiveSCUMMutatingStep(serverID string) (bool, error) { + mutates := true + for _, status := range []domain.SCUMWorkflowStepStatus{domain.SCUMWorkflowStepRunning, domain.SCUMWorkflowStepConfirming} { + steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, Status: status, MutatesState: &mutates}) + if err != nil { + return false, err + } + if len(steps) > 0 { + return true, nil + } + } + return false, nil +} + +func (svc *CoreService) blockSCUMStepIfRunUnavailable(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep) (domain.SCUMWorkflowStep, error) { + if strings.TrimSpace(step.Capability) == "" { + return domain.SCUMWorkflowStep{}, nil + } + instance, err := svc.store.ServerInstances().Get(workflow.ServerInstanceID) + if err != nil { + return domain.SCUMWorkflowStep{}, err + } + endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + if err == repo.ErrNotFound { + return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "No bound run endpoint is available for this typed SCUM workflow step.") + } + return domain.SCUMWorkflowStep{}, err + } + if err := svc.validateRunnableEndpoint(endpoint, step.Capability); err != nil { + return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "Bound run cannot currently claim the declared workflow capability.") + } + return domain.SCUMWorkflowStep{}, nil +} + +func (svc *CoreService) blockSCUMWorkflowStep(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep, title string, message string) (domain.SCUMWorkflowStep, error) { + stamp := svc.now() + step.Status = domain.SCUMWorkflowStepBlocked + step.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"stepKey": step.StepKey, "capability": step.Capability}} + step.UpdatedAt = stamp + workflow.Status = domain.SCUMWorkflowBlocked + workflow.CurrentStepKey = step.StepKey + workflow.BlockerReason = title + workflow.SafeSummary = step.SafeSummary + workflow.UpdatedAt = stamp + if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { + return domain.SCUMWorkflowStep{}, err + } + if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { + return domain.SCUMWorkflowStep{}, err + } + return domain.CopySCUMWorkflowStep(step), nil +} + +func (svc *CoreService) refreshSCUMWorkflowStatus(workflow domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) { + steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID) + if err != nil { + return domain.SCUMWorkflowInstance{}, err + } + allConfirmed := len(steps) > 0 + stamp := svc.now() + for _, step := range steps { + switch step.Status { + case domain.SCUMWorkflowStepFailed: + workflow.Status = domain.SCUMWorkflowFailed + case domain.SCUMWorkflowStepUnknown: + workflow.Status = domain.SCUMWorkflowUnknown + case domain.SCUMWorkflowStepCancelled: + workflow.Status = domain.SCUMWorkflowCancelled + case domain.SCUMWorkflowStepConfirmed: + default: + allConfirmed = false + } + if workflow.Status == domain.SCUMWorkflowFailed || workflow.Status == domain.SCUMWorkflowUnknown || workflow.Status == domain.SCUMWorkflowCancelled { + workflow.CurrentStepKey = step.StepKey + workflow.CompletedAt = stamp + workflow.UpdatedAt = stamp + return domain.CopySCUMWorkflowInstance(workflow), svc.store.SCUMWorkflowInstances().Update(workflow) + } + } + if allConfirmed { + workflow.Status = domain.SCUMWorkflowConfirmed + workflow.CurrentStepKey = "" + workflow.CompletedAt = stamp + } else { + workflow.Status = domain.SCUMWorkflowQueued + workflow.CurrentStepKey = "" + } + workflow.UpdatedAt = stamp + if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { + return domain.SCUMWorkflowInstance{}, err + } + return domain.CopySCUMWorkflowInstance(workflow), nil +} + +func scumWorkflowDependenciesConfirmed(step domain.SCUMWorkflowStep, steps []domain.SCUMWorkflowStep) bool { + if len(step.DependsOn) == 0 { + return true + } + statuses := map[string]domain.SCUMWorkflowStepStatus{} + for _, candidate := range steps { + statuses[candidate.StepKey] = candidate.Status + } + for _, dependency := range step.DependsOn { + if statuses[dependency] != domain.SCUMWorkflowStepConfirmed { + return false + } + } + return true +} + +func scumWorkflowRunnable(status domain.SCUMWorkflowStatus) bool { + switch status { + case domain.SCUMWorkflowQueued, domain.SCUMWorkflowRunning, domain.SCUMWorkflowWaiting: + return true + default: + return false + } +} + +func scumWorkflowStepRunnable(status domain.SCUMWorkflowStepStatus) bool { + switch status { + case domain.SCUMWorkflowStepQueued, domain.SCUMWorkflowStepWaiting: + return true + default: + return false + } +} + +func scumWorkflowStepTerminal(status domain.SCUMWorkflowStepStatus) bool { + switch status { + case domain.SCUMWorkflowStepConfirmed, domain.SCUMWorkflowStepFailed, domain.SCUMWorkflowStepUnknown, domain.SCUMWorkflowStepCancelled: + return true + default: + return false + } +} + +func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition { + read := domain.JobCapabilityRemoteRunDBSQLiteQuery + logs := domain.JobCapabilityRemoteRunLogsTransfer + protectedSQL := domain.JobCapabilityRemoteRunProtectedSQL + rcon := domain.JobCapabilityRemoteRunRCONCommand + return map[string]scumWorkflowTemplateDefinition{ + "scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM real data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before projection refresh."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log observation cursor."}}}, + "scum.player-refresh": {Key: "scum.player-refresh", Title: "Refresh SCUM player", Steps: []scumWorkflowStepDefinition{{Key: "login-evidence", Capability: logs, TargetKey: "scum-login", Summary: "Sync login/logout evidence."}, {Key: "player-profile", DependsOn: []string{"login-evidence"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Read player profile/economy facts."}, {Key: "position-read", DependsOn: []string{"player-profile"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Read current player coordinates."}}}, + "scum.world-refresh": {Key: "scum.world-refresh", Title: "Refresh SCUM world", Steps: []scumWorkflowStepDefinition{{Key: "squad-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squads", MaxAttempts: 2, Summary: "Refresh squads."}, {Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", MaxAttempts: 2, Summary: "Refresh vehicles."}, {Key: "flag-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", MaxAttempts: 2, Summary: "Refresh flags."}, {Key: "position-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", MaxAttempts: 2, Summary: "Refresh map positions."}}}, + "scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current projection, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved typed operation through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}}, + "scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through typed operation."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}}, + "scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory audit", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Project stale owner/member risk signals."}}}, + "scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle audit", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}}, + "scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and workflow inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable typed diff or workflow draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human approval through typed paths."}}}, + "scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe workflow/status surfaces."}}}, + } +} diff --git a/platform/service/scum_workflows_test.go b/platform/service/scum_workflows_test.go new file mode 100644 index 0000000..014da28 --- /dev/null +++ b/platform/service/scum_workflows_test.go @@ -0,0 +1,132 @@ +package service + +import ( + "strings" + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +func TestSCUMWorkflowDispatchesReadStepsWithBoundedConcurrencyAndIdempotency(t *testing.T) { + svc, session, instance := newSCUMWorkflowFixture(t, true) + workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1", Input: map[string]any{"scope": "world"}}) + if err != nil || workflow.Status != domain.SCUMWorkflowQueued { + t.Fatalf("create world workflow=%+v err=%v", workflow, err) + } + duplicate, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1"}) + if err != nil || duplicate.ID != workflow.ID { + t.Fatalf("expected idempotent workflow create: duplicate=%+v err=%v", duplicate, err) + } + dispatched, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 3) + if err != nil || len(dispatched) != 3 { + t.Fatalf("expected three bounded read steps dispatched: steps=%+v err=%v", dispatched, err) + } + for _, step := range dispatched { + if step.MutatesState || step.Status != domain.SCUMWorkflowStepRunning || step.Attempt != 1 { + t.Fatalf("unexpected read step dispatch: %+v", step) + } + } +} + +func TestSCUMWorkflowSerializesMutatingStepsPerServer(t *testing.T) { + svc, session, instance := newSCUMWorkflowFixture(t, true) + first, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-1"}) + if err != nil { + t.Fatalf("create first gift workflow: %v", err) + } + if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-2"}); err != nil { + t.Fatalf("create second gift workflow: %v", err) + } + steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) + if err != nil || len(steps) != 1 || steps[0].StepKey != "eligibility-check" { + t.Fatalf("expected first eligibility step: steps=%+v err=%v", steps, err) + } + if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil { + t.Fatalf("complete eligibility: %v", err) + } + steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) + if err != nil || len(steps) != 1 || steps[0].StepKey != "deliver-reward" || !steps[0].MutatesState { + t.Fatalf("expected first mutating reward step: steps=%+v err=%v", steps, err) + } + if steps[0].WorkflowID != first.ID { + t.Fatalf("expected first workflow to keep the mutation slot: step=%+v first=%+v", steps[0], first) + } + blockedByActiveMutation, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) + if err != nil { + t.Fatalf("dispatch while mutation active: %v", err) + } + for _, step := range blockedByActiveMutation { + if step.MutatesState { + t.Fatalf("second state-changing step should wait for first terminal state: steps=%+v", blockedByActiveMutation) + } + } +} + +func TestSCUMWorkflowBlocksWhenRunUnavailable(t *testing.T) { + svc, session, instance := newSCUMWorkflowFixture(t, false) + workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.player-refresh", IdempotencyKey: "player-refresh-blocked"}) + if err != nil { + t.Fatalf("create player refresh workflow: %v", err) + } + steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) + if err != nil || len(steps) != 1 || steps[0].Status != domain.SCUMWorkflowStepBlocked { + t.Fatalf("expected blocked run step: steps=%+v err=%v", steps, err) + } + updated, err := svc.store.SCUMWorkflowInstances().Get(workflow.ID) + if err != nil || updated.Status != domain.SCUMWorkflowBlocked || strings.Contains(updated.SafeSummary.Message, "/") || strings.Contains(strings.ToLower(updated.SafeSummary.Message), "token") { + t.Fatalf("workflow blocker should be safe: workflow=%+v err=%v", updated, err) + } +} + +func TestSCUMWorkflowRetryRequiresConfirmationAfterUnknownMutation(t *testing.T) { + svc, session, instance := newSCUMWorkflowFixture(t, true) + if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-unknown"}); err != nil { + t.Fatalf("create gift workflow: %v", err) + } + steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) + if err != nil || len(steps) != 1 { + t.Fatalf("dispatch eligibility: steps=%+v err=%v", steps, err) + } + if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil { + t.Fatalf("complete eligibility: %v", err) + } + steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) + if err != nil || len(steps) != 1 || !steps[0].MutatesState { + t.Fatalf("dispatch mutating reward: steps=%+v err=%v", steps, err) + } + if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepUnknown, domain.SCUMOperationConfirmation{Status: "unknown"}); err != nil { + t.Fatalf("complete unknown mutation: %v", err) + } + retry, err := svc.RetrySCUMWorkflowStep(steps[0].ID) + if err != nil || retry.Status != domain.SCUMWorkflowStepUnknown || !strings.Contains(retry.SafeSummary.Title, "确认") { + t.Fatalf("unknown mutating retry should require confirmation: step=%+v err=%v", retry, err) + } +} + +func newSCUMWorkflowFixture(t *testing.T, runAvailable bool) (*CoreService, string, domain.ServerInstance) { + t.Helper() + svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime }) + capabilities := []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunRCONCommand} + plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ID: "server.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", ManifestRef: "artifact://manifests/server.scum/1.0.0", CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", RequiredRunCapabilities: capabilities, DeclaredPermissions: []string{"server.game-client.read", "server.game-client.command", "server.game-client.maintenance"}, Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: capabilities, DatabaseEngines: []string{"sqlite"}, RCON: true, LogTransfer: true}, LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}, {Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}}}}}) + if err != nil { + t.Fatalf("create workflow plugin: %v", err) + } + endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: capabilities, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: fixedTime}) + if err != nil { + t.Fatalf("create workflow endpoint: %v", err) + } + session := createServiceUserAndLogin(t, svc, domain.User{ID: "workflow-owner", DisplayName: "Workflow Owner", Email: "workflow-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-workflow", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Workflow Server", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create workflow server: %v", err) + } + if !runAvailable { + endpoint.Status = domain.RunEndpointStatusOffline + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("mark workflow endpoint offline: %v", err) + } + } + return svc, session, instance +} diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 8cebd49..b78fe3a 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -439,7 +439,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) - if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { + if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { return nil } var violations []string @@ -590,6 +590,72 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability") } } + operationTemplates := map[string]domain.GameClientBridgeOperationTemplateDeclaration{} + for index, template := range bridge.OperationTemplates { + prefix := fmt.Sprintf("%s.operationTemplates[%d]", field, index) + if !clientManagerIdentifierPattern.MatchString(template.Key) || unsafeGameClientBridgeCommandType(template.Key) { + violations = append(violations, prefix+".key is invalid or unsafe") + } + if _, exists := operationTemplates[template.Key]; exists { + violations = append(violations, prefix+".key is duplicated") + } + operationTemplates[template.Key] = template + if strings.TrimSpace(template.Title) == "" || len([]rune(template.Title)) > 80 { + violations = append(violations, prefix+".title is invalid") + } + if !containsString(permissions, template.Permission) { + violations = append(violations, prefix+".permission must be declared by the plugin") + } + if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { + violations = append(violations, prefix+".approvalLevel must require operator or platform-admin approval") + } + if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation { + violations = append(violations, prefix+".kind is invalid") + } + if !safeRelativeJSONRef(template.PayloadSchemaRef) || template.ResultSchemaRef != "" && !safeRelativeJSONRef(template.ResultSchemaRef) || template.ConfirmationSchemaRef != "" && !safeRelativeJSONRef(template.ConfirmationSchemaRef) { + violations = append(violations, prefix+" schema references must be safe relative JSON references") + } + if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 3600 { + violations = append(violations, prefix+".timeoutSeconds is invalid") + } + if template.MaxPayloadBytes < 1 || template.MaxPayloadBytes > maxGameClientBridgePayloadSize { + violations = append(violations, prefix+".maxPayloadBytes is invalid") + } + transport, exists := transports[template.TransportKey] + if !exists { + violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile") + continue + } + if transport.TargetKey != template.TargetKey || strings.TrimSpace(template.TargetKey) == "" { + violations = append(violations, prefix+".targetKey must match the declared runtime transport profile") + } + switch template.Kind { + case domain.GameClientBridgeOperationKindRCON: + if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedRCON) { + violations = append(violations, prefix+" transport must be rcon with remote.run.protected.rcon capability") + } + if template.MaxRowsAffected != 0 { + violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation") + } + if !emptyGameClientBridgeOperationMutation(template.Mutation) { + violations = append(violations, prefix+".mutation is only valid for sqlite-mutation") + } + case domain.GameClientBridgeOperationKindSQLiteMutation: + if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL) { + violations = append(violations, prefix+" transport must be sqlite with remote.run.protected.sql capability") + } + if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { + violations = append(violations, prefix+".approvalLevel must require platform-admin approval for sqlite-mutation") + } + if template.MaxRowsAffected < 1 || template.MaxRowsAffected > 10 { + violations = append(violations, prefix+".maxRowsAffected is invalid") + } + if !template.Safety.RequiresBeforeValue || !template.Safety.RequiresConfirmation || (!template.Safety.RequiresOfflinePlayer && !template.Safety.RequiresMaintenanceWindow) { + violations = append(violations, prefix+".safety must require before value, confirmation, and offline or maintenance protection") + } + violations = append(violations, validateGameClientBridgeOperationMutation(prefix+".mutation", template.Mutation, queryTemplates)...) + } + } pageDeclarations := map[string]domain.GamePluginPage{} for _, page := range pages { pageDeclarations[page.Key] = page @@ -674,6 +740,16 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid violations = append(violations, prefix+" must declare remote.access.request for query templates") } } + for _, operationKey := range page.OperationKeys { + operation, exists := operationTemplates[operationKey] + if !exists { + violations = append(violations, prefix+" references undeclared operation template "+operationKey) + continue + } + if !containsString(pageDeclaration.Permissions, operation.Permission) { + violations = append(violations, prefix+" must declare operation template permission "+operation.Permission) + } + } for _, featureKey := range page.FeatureKeys { feature, exists := features[featureKey] if !exists { @@ -772,6 +848,31 @@ func validateGameClientBridgeProtectedRequest(prefix string, request *domain.Gam return violations } +func emptyGameClientBridgeOperationMutation(value domain.GameClientBridgeOperationMutationDeclaration) bool { + return value.FieldKey == "" && value.TableKey == "" && value.IdentityKey == "" && value.ValueKey == "" && value.ConfirmationQueryKey == "" && value.AllowedValueType == "" && value.MinValue == 0 && value.MaxValue == 0 +} + +func validateGameClientBridgeOperationMutation(prefix string, value domain.GameClientBridgeOperationMutationDeclaration, queryTemplates map[string]domain.GameClientBridgeQueryTemplateDeclaration) []string { + var violations []string + for field, item := range map[string]string{"fieldKey": value.FieldKey, "tableKey": value.TableKey, "identityKey": value.IdentityKey, "valueKey": value.ValueKey, "confirmationQueryKey": value.ConfirmationQueryKey} { + if !validDistributionLogicalKey(item) || unsafeGameClientBridgePayloadKey(item) { + violations = append(violations, prefix+"."+field+" must be a safe logical key") + } + } + if !oneOf(value.AllowedValueType, "integer", "number", "string", "boolean") { + violations = append(violations, prefix+".allowedValueType is invalid") + } + if value.MaxValue != 0 && value.MinValue > value.MaxValue { + violations = append(violations, prefix+".minValue must not exceed maxValue") + } + if value.ConfirmationQueryKey != "" { + if _, exists := queryTemplates[value.ConfirmationQueryKey]; !exists { + violations = append(violations, prefix+".confirmationQueryKey must reference a declared query template") + } + } + return violations +} + func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error { var violations []string violations = appendRequired(violations, "pluginId", request.PluginID) diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index 3933cd7..71af29a 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -129,17 +129,25 @@ func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testi func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) { registration := validGamePluginManifestRegistration() - registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.remote.access") - registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) - registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.remote.access") + registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access") + registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL) + registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access") registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) - registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}} + registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{ + {Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}, + {Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedRCON}}, + {Key: "scum-mutation-db", Kind: "sqlite", TargetKey: "scum-mutation-db", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}, + } registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{ Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, - Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, - Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}}}, + OperationTemplates: []domain.GameClientBridgeOperationTemplateDeclaration{ + {Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-fame-set.confirmation.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, + {Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "player.lookup", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}, + }, + Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, + Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}}, } if err := ValidateGamePluginManifestRegistration(registration); err != nil { t.Fatalf("expected bridge catalog to validate, got %v", err) @@ -225,6 +233,55 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog( } }) } + + operationTemplateTests := []struct { + name string + expected string + mutate func(*domain.GamePluginManifestRegistration) + }{ + {name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.OperationTemplates = append(value.Manifest.GameClientBridge.OperationTemplates, value.Manifest.GameClientBridge.OperationTemplates[0]) + }}, + {name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute" + }}, + {name: "missing approval", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.OperationTemplates[0].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone + }}, + {name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.OperationTemplates[0].PayloadSchemaRef = "/etc/operation.json" + }}, + {name: "rcon wrong transport", expected: "transport must be rcon", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.OperationTemplates[0].TransportKey = "sqlite-db" + value.Manifest.GameClientBridge.OperationTemplates[0].TargetKey = "db/sqlite" + }}, + {name: "mutation wrong transport", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.OperationTemplates[1].TransportKey = "scum-rcon" + value.Manifest.GameClientBridge.OperationTemplates[1].TargetKey = "scum-rcon" + }}, + {name: "mutation row bound", expected: "maxRowsAffected is invalid", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.OperationTemplates[1].MaxRowsAffected = 0 + }}, + {name: "mutation missing safety", expected: "safety must require", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.OperationTemplates[1].Safety.RequiresBeforeValue = false + }}, + {name: "undeclared page operation", expected: "undeclared operation template", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.Pages[0].OperationKeys = []string{"missing.operation"} + }}, + {name: "page missing operation permission", expected: "must declare operation template permission", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.Pages[0].Permissions = []string{"server.game-client.read", "server.remote.access"} + }}, + } + for _, test := range operationTemplateTests { + t.Run("operation template "+test.name, func(t *testing.T) { + invalid := domain.CopyGamePluginManifestRegistration(registration) + test.mutate(&invalid) + err := ValidateGamePluginManifestRegistration(invalid) + if err == nil || !strings.Contains(err.Error(), test.expected) { + t.Fatalf("expected %q rejection, got %v", test.expected, err) + } + }) + } } func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) { diff --git a/platform_web/acceptance/browser-acceptance.mjs b/platform_web/acceptance/browser-acceptance.mjs index cfd0759..3d30657 100644 --- a/platform_web/acceptance/browser-acceptance.mjs +++ b/platform_web/acceptance/browser-acceptance.mjs @@ -178,14 +178,10 @@ async function main() { markers: [ server.name, `${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`, - "概览", "启动", "停止", - "日志", - "管理终端", - "配置", - "AI 助手", - "操作历史" + "管理", + "AI 助手" ] } ]; @@ -818,8 +814,8 @@ async function verifyLifecycleOperation(headers, server, chrome) { const job = await waitForJob(headers, currentServer.id, result.job.id); await chrome.navigate(`${webUrl}/#/servers/${encodeURIComponent(currentServer.id)}`); - await chrome.waitForText([currentServer.name, "操作历史"], "server detail after lifecycle operation"); - const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]); + await chrome.waitForText([currentServer.name, "管理", "AI 助手"], "server detail after lifecycle operation"); + const manageState = await clickAndVerify(chrome, "管理", ["部署定义", "基本信息", "管理成员"]); return { action, @@ -832,10 +828,10 @@ async function verifyLifecycleOperation(headers, server, chrome) { }, acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]), job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]), - proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access`, + proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified safe management entry point without direct run access`, browserEvidence: { - ...historyState, - proofMode: "operation-history-entry-point" + ...manageState, + proofMode: "safe-management-entry-point" } }; } diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index fd9627a..07dad86 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -244,61 +244,18 @@ describe("PlatformApiClient AI providers", () => { count: 1 }); } - if (url.endsWith("/api/v1/server-instances/server-1/config")) { - return jsonResponse({ - serverInstanceId: server.id, - configVersion: 1, - format: "properties", - key: "server.properties", - content: "server.name=Example Survival #1\n", - source: "platform-derived", - updatedAt: "2026-07-03T00:00:00Z" - }); - } - if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") { - expect(JSON.parse(String(init.body))).toEqual({ - expectedConfigVersion: 1, - key: "server.properties", - proposedContent: "server.name=Example Survival #2\n" - }); - return jsonResponse({ - serverInstanceId: server.id, - configVersion: 1, - key: "server.properties", - currentContent: "server.name=Example Survival #1\n", - proposedContent: "server.name=Example Survival #2\n", - diff: [ - { kind: "removed", oldNumber: 1, content: "server.name=Example Survival #1" }, - { kind: "added", newNumber: 1, content: "server.name=Example Survival #2" } - ], - hasChanges: true, - source: "platform-review", - reviewedAt: "2026-07-03T00:00:00Z" - }); - } - if (url.endsWith("/api/v1/server-instances/server-1/config/approve") && init?.method === "POST") { - expect(JSON.parse(String(init.body))).toEqual({ - expectedConfigVersion: 1, - key: "server.properties", - proposedContent: "server.name=Example Survival #2\n", - idempotencyKey: "idem-config" - }); - return jsonResponse({ - status: "queued", - preview: { - serverInstanceId: server.id, - configVersion: 1, - key: "server.properties", - currentContent: "server.name=Example Survival #1\n", - proposedContent: "server.name=Example Survival #2\n", - diff: [{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }], - hasChanges: true, - source: "platform-review", - reviewedAt: "2026-07-03T00:00:00Z" - }, - job: { ...job, id: "job-config-write", capability: "config.write", targetKey: "server.properties", inputRef: "input://server-config/server-1/server.properties/v1" } - }); - } + if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/positions")) return jsonResponse({ items: [{ id: "position-1", subjectType: "player", subjectId: "steam-1", x: 1, y: 2, z: 3 }], count: 1 }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "waiting", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/operations/op-1/approve") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "queued", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && init?.method === "POST") return jsonResponse({ id: "workflow-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "scum.world-refresh", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); + if (url.endsWith("/api/v1/server-instances/server-1/scum/workflow-steps?workflowId=workflow-1")) return jsonResponse({ items: [{ id: "step-1", workflowId: "workflow-1", serverInstanceId: server.id, stepKey: "read-positions", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }], count: 1 }); if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ serverInstanceId: server.id, @@ -536,13 +493,6 @@ describe("PlatformApiClient AI providers", () => { expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" }); return jsonResponse({ ...job, id: "job-dep-install", capability: "dependencies.install", targetKey: "dependencies/install/install-java-linux" }); } - if (url.endsWith("/api/v1/server-instances/server-1/logs/live")) { - return jsonResponse({ items: [], count: 0 }); - } - if (url.endsWith("/api/v1/server-instances/server-1/logs/backfill") && init?.method === "POST") { - expect(JSON.parse(String(init.body))).toEqual({ sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" }); - return jsonResponse({ ...job, id: "job-log-backfill", capability: "logs.backfill", targetKey: "logs/latest-log", inputRef: "artifact://logs/checkpoint/1" }); - } if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") { return jsonResponse({ pluginId: plugin.id, @@ -604,13 +554,18 @@ describe("PlatformApiClient AI providers", () => { await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined(); await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 }); await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] }); - await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" }); - await expect( - client.previewServerConfigDiff(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n" }) - ).resolves.toMatchObject({ hasChanges: true, source: "platform-review" }); - await expect( - client.approveServerConfigWrite(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n", idempotencyKey: "idem-config" }) - ).resolves.toMatchObject({ status: "queued", job: { capability: "config.write", targetKey: "server.properties" } }); + await expect(client.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] }); + await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 }); + await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 }); + await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 }); + await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 }); + await expect(client.listSCUMPositions(server.id)).resolves.toMatchObject({ count: 1 }); + await expect(client.listSCUMOperations(server.id)).resolves.toMatchObject({ count: 0 }); + await expect(client.createSCUMOperation(server.id, { templateKey: "player.fame.set", playerId: "steam-1", payload: { fame: 100 }, reason: "typed correction", idempotencyKey: "idem-scum-op" })).resolves.toMatchObject({ id: "op-1", status: "waiting" }); + await expect(client.approveSCUMOperation(server.id, "op-1")).resolves.toMatchObject({ id: "op-1", status: "queued" }); + await expect(client.listSCUMWorkflows(server.id)).resolves.toMatchObject({ count: 0 }); + await expect(client.createSCUMWorkflow(server.id, { templateKey: "scum.world-refresh", idempotencyKey: "idem-scum-workflow" })).resolves.toMatchObject({ id: "workflow-1", status: "queued" }); + await expect(client.listSCUMWorkflowSteps(server.id, "workflow-1")).resolves.toMatchObject({ count: 1, items: [{ stepKey: "read-positions" }] }); await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({ status: "queued", job: { capability: "files.read", targetKey: "logs/latest.log" } @@ -665,21 +620,11 @@ describe("PlatformApiClient AI providers", () => { await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({ capability: "dependencies.install" }); - await expect(client.listServerLiveLogs(server.id)).resolves.toMatchObject({ count: 0 }); - await expect(client.requestLogBackfill(server.id, { sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" })).resolves.toMatchObject({ - capability: "logs.backfill" - }); - await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({ - allowed: true - }); - await expect( - client.executePluginBridge({ requestId: "req-bridge", pluginId: plugin.id, routeKey: "logs", serverInstanceId: server.id, action: "logs.query", payload: { logStreamId: "log-1" } }) - ).resolves.toMatchObject({ status: "ok", result: { entryCount: "0" } }); await expect( client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } }); - expect(fetchMock).toHaveBeenCalledTimes(43); + expect(fetchMock).toHaveBeenCalledTimes(48); }); it("calls plugin marketplace endpoints with filter and state contracts", async () => { @@ -708,11 +653,11 @@ describe("PlatformApiClient AI providers", () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); - it("surfaces config diff preview failures from the platform", async () => { + it("surfaces SCUM typed operation failures from the platform", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") { - return new Response(JSON.stringify({ code: "validation", message: "expectedConfigVersion must match server instance" }), { + if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") { + return new Response(JSON.stringify({ code: "validation", message: "SCUM operation template is not declared" }), { status: 400, headers: { "Content-Type": "application/json" } }); @@ -723,9 +668,7 @@ describe("PlatformApiClient AI providers", () => { const client = new PlatformApiClient(); - await expect(client.previewServerConfigDiff(server.id, { expectedConfigVersion: 0, key: "server.properties", proposedContent: "changed=true\n" })).rejects.toThrow( - "expectedConfigVersion must match server instance" - ); + await expect(client.createSCUMOperation(server.id, { templateKey: "raw.sql", reason: "unsafe", idempotencyKey: "bad-scum-op" })).rejects.toThrow("SCUM operation template is not declared"); }); it("keeps raw key and base URL fields out of provider responses", () => { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 44f1960..2ff216d 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -36,7 +36,6 @@ import type { CurrentUserResponse, DependencyCatalogResponse, DependencyJobRequest, - DeclaredFileReadSnapshotResponse, FileOperationDispatchRequest, FileOperationDispatchResponse, GameClientBridgeCancelRequest, @@ -55,8 +54,6 @@ import type { JobResponse, LlmConfigSuggestionRequest, LlmConfigSuggestionResponse, - LogBackfillRequest, - LogStreamEventOptions, LogStreamCursorRequest, LogStreamCursorResponse, LogStreamListResponse, @@ -84,19 +81,12 @@ import type { RunUpdateRequest, RuntimeBindingResponse, RuntimeBindingUpdateRequest, - ServerConfigResponse, - ServerConfigDiffPreviewRequest, - ServerConfigDiffPreviewResponse, ServerLifecycleCommandRequest, ServerLifecycleCreateRequest, ServerLifecycleResponse, ServerDeploymentRequest, ServerDeploymentRevealResponse, ServerDeploymentResponse, - ServerConfigWriteApprovalRequest, - ServerConfigWriteDispatchResponse, - SourceRCONCommandRequest, - SourceRCONCommandResponse, ServerInstanceListResponse, ServerDeletionRequest, ServerInstanceUpdateRequest, @@ -110,6 +100,14 @@ import type { RemoteAdapterDeclarationListResponse, RemoteAdapterRequest, RemoteAdapterResponse, + SCUMListResponse, + SCUMOperationListResponse, + SCUMOperationRequest, + SCUMOperationResponse, + SCUMWorkflowCreateRequest, + SCUMWorkflowListResponse, + SCUMWorkflowResponse, + SCUMWorkflowStepListResponse, ServerRuntimeActionsResponse, UserCreateRequest, UserListResponse, @@ -444,28 +442,6 @@ export class PlatformApiClient { }); } - async listServerLiveLogs(id: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(id)}/logs/live`); - } - - openServerLogEvents(id: string, options: LogStreamEventOptions = {}): EventSource { - return new EventSource(this.serverLogEventsUrl(id, options), { withCredentials: true }); - } - - serverLogEventsUrl(id: string, options: LogStreamEventOptions = {}): string { - const params = new URLSearchParams(); - if (options.historyLimit !== undefined) params.set("historyLimit", String(options.historyLimit)); - const query = params.toString(); - return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query ? `?${query}` : ""}`; - } - - async requestLogBackfill(id: string, request: LogBackfillRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(id)}/logs/backfill`, { - method: "POST", - body: request - }); - } - async createJob(request: JobCreateRequest): Promise { return this.request("/jobs", { method: "POST", body: request }); } @@ -599,26 +575,55 @@ export class PlatformApiClient { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request }); } - async sendSourceRCONCommand(serverInstanceId: string, request: SourceRCONCommandRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/rcon/commands`, { method: "POST", body: request }); + async listSCUMPlayers(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`); } - async getServerConfig(id: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(id)}/config`); + async listSCUMSquads(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`); } - async previewServerConfigDiff(id: string, request: ServerConfigDiffPreviewRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(id)}/config/diff`, { - method: "POST", - body: request - }); + async listSCUMSquadMembers(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squad-members`); } - async approveServerConfigWrite(id: string, request: ServerConfigWriteApprovalRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(id)}/config/approve`, { - method: "POST", - body: request - }); + async listSCUMVehicles(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles`); + } + + async listSCUMFlags(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/flags`); + } + + async listSCUMPositions(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`); + } + + async listSCUMOperations(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`); + } + + async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request }); + } + + async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} }); + } + + async listSCUMWorkflows(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`); + } + + async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request }); + } + + async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise { + const params = new URLSearchParams(); + if (workflowId) params.set("workflowId", workflowId); + const query = params.toString(); + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`); } async dispatchFileOperation(request: FileOperationDispatchRequest): Promise { @@ -628,11 +633,6 @@ export class PlatformApiClient { }); } - async getDeclaredFileReadSnapshot(serverInstanceId: string, fileKey: string): Promise { - const query = new URLSearchParams({ key: fileKey }); - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${query.toString()}`); - } - async listLogStreams(): Promise { return this.request("/log-streams"); } diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index efe2174..6193c36 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -6,12 +6,12 @@ API clients and DTO types live here, not inside page components. - `users`: user and role APIs. - `serverPlugins`: plugin marketplace and installed plugin APIs. -- `serverInstances`: create server, lifecycle, config read, config diff/approval, scoped files, logs, and detail APIs. +- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM typed projection/workflow APIs. - `aiProviders`: provider CRUD, test, and model APIs. - `jobs`: job status and operation APIs. - `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs. - `artifacts`: artifact upload/download APIs. -- `logs`: historical query and tail APIs. +- `logs`: internal historical cursor APIs used for maintenance/debug views, not server-detail raw log products. - `pluginPageBridge`: safe bridge APIs for hosted plugin page. Every API client must use named request and response types. @@ -26,8 +26,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia - `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs for internal/advanced logical transports. Server detail must not expose a manual runtime-binding tab or require these fields before normal start/stop when plugin-declared deployment/lifecycle data is sufficient. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values. - `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. -- `getServerConfig`, `previewServerConfigDiff`, and `approveServerConfigWrite` call platform-mediated config routes. ServerDetailPage must preview the platform diff first, keep the explicit confirmation step, and dispatch writes only through the approval API. -- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths. +- SCUM projection reads use `listSCUMPlayers`, `listSCUMSquads`, `listSCUMSquadMembers`, `listSCUMVehicles`, `listSCUMFlags`, and `listSCUMPositions`; SCUM writes use `createSCUMOperation`, `approveSCUMOperation`, `createSCUMWorkflow`, and workflow/step list APIs. These APIs expose only projection rows, typed template keys, status, and safe summaries, never SQL text, RCON text, DSNs, host paths, or protected payloads. +- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench. - `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior. - `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions. - `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential. @@ -52,15 +52,14 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata - `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference. - `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen. - `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards and the server detail header. -- `GET /api/v1/server-instances/{id}/config` (`ServerConfigResponse`): implemented readable configuration content for diff-based editing. -- `POST /api/v1/server-instances/{id}/config/diff` (`ServerConfigDiffPreviewRequest`/`ServerConfigDiffPreviewResponse`) and `POST /api/v1/server-instances/{id}/config/approve` (`ServerConfigWriteApprovalRequest`/`ServerConfigWriteDispatchResponse`): implemented platform-mediated config write review and approval. Manual config edits and AI suggestion applies must not create generic `config.write` jobs through `POST /api/v1/jobs`. +- Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` plus reviewable AI config-diff approval APIs; plugin pages do not receive raw config text. - `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only. - `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets. - Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals. - Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent. Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access. -- Live log and management-terminal output uses `GET /api/v1/server-instances/{id}/logs/events` as a single `EventSource`/SSE stream with bounded initial history. `POST /api/v1/log-streams/query` remains available for explicit historical cursor reads and reconnect repair, not periodic browser polling. +- Server-scoped raw log routes (`GET /api/v1/server-instances/{id}/logs/live`, `GET .../logs/events`, `POST .../logs/backfill`) and management-terminal/RCON input routes are removed from product clients. Internal log ingest and cursor query remain available to platform services and maintenance/debug flows. # Client Manager API projection `PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client. diff --git a/platform_web/api/sourceRcon.test.ts b/platform_web/api/sourceRcon.test.ts deleted file mode 100644 index 4d1ae2a..0000000 --- a/platform_web/api/sourceRcon.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { PlatformApiClient } from "./client"; -import type { SourceRCONCommandRequest } from "./types"; - -describe("PlatformApiClient Source RCON command dispatch", () => { - afterEach(() => vi.unstubAllGlobals()); - - it("posts the typed server-scoped request and projects only safe queue state", async () => { - const request: SourceRCONCommandRequest = { - kind: "chat", - chatType: 4, - message: "Maintenance complete", - targetSteamId: "76561198000000001", - idempotencyKey: "web:source-rcon:chat:server-1:12" - }; - const calls: Array<{ url: string; method: string; body?: unknown; authorization: string | null }> = []; - vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - calls.push({ - url: String(input), - method: init?.method ?? "GET", - body: init?.body ? JSON.parse(String(init.body)) : undefined, - authorization: new Headers(init?.headers).get("Authorization") - }); - return new Response(JSON.stringify({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" }), { - status: 202, - headers: { "Content-Type": "application/json" } - }); - })); - - const response = await new PlatformApiClient("/api/v1", () => "operator-session").sendSourceRCONCommand("server/1", request); - - expect(calls).toEqual([{ - url: "/api/v1/server-instances/server%2F1/rcon/commands", - method: "POST", - body: request, - authorization: "Bearer operator-session" - }]); - expect(response).toEqual({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" }); - for (const forbidden of ["command", "password", "host", "configRef", "response"]) { - expect(Object.keys(response)).not.toContain(forbidden); - } - }); -}); diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 4179574..b3afca7 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -1373,6 +1373,17 @@ export interface RemoteAdapterResponse { completedAt?: string; } +export type SCUMJsonRecord = Record; +export interface SCUMListResponse { items: T[]; count: number; } +export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; } +export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; } +export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; } +export interface SCUMWorkflowStepResponse { id: string; workflowId: string; serverInstanceId: string; stepKey: string; dependsOn?: string[]; status: string; operationKey?: string; queryTemplateKey?: string; capability?: string; targetKey?: string; jobId?: string; attempt?: number; maxAttempts?: number; mutatesState?: boolean; confirmation?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; } +export interface SCUMOperationResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; playerId?: string; requesterId?: string; approverId?: string; approvalLevel: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; confirmation?: SCUMJsonRecord; status: string; reason?: string; runJobId?: string; safeSummary?: SCUMJsonRecord; auditReferences?: string[]; createdAt: string; approvedAt?: string; completedAt?: string; updatedAt: string; } +export type SCUMWorkflowListResponse = SCUMListResponse; +export type SCUMWorkflowStepListResponse = SCUMListResponse; +export type SCUMOperationListResponse = SCUMListResponse; + export interface ServerConfigResponse { serverInstanceId: string; configVersion: number; diff --git a/platform_web/components/ServerLiveOperations.tsx b/platform_web/components/ServerLiveOperations.tsx deleted file mode 100644 index 10b67a5..0000000 --- a/platform_web/components/ServerLiveOperations.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; -import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { platformApiClient } from "../api/client"; -import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types"; -import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon"; -import { cx } from "../utils/classes"; -import { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents"; -import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews"; - -type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; -type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number }; -type TerminalQuickCommand = { label: string; command: string; hint: string }; - -const terminalBridgeResultPollMs = 1000; -const terminalBridgeResultPollAttempts = 30; -const terminalInitialHistoryWindow = 500; -const maxTerminalLines = 10000; -const terminalQuickCommandCatalog: Record = { - "game.scum": [ - { label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" }, - { label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" }, - { label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" }, - { label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" } - ] -}; - -interface LiveOperationDrawerProps { - open: boolean; - title: string; - description?: string; - onClose: () => void; - children: ReactNode; - backdropClassName?: string; - panelClassName?: string; - bodyClassName?: string; - hideHeader?: boolean; -} - -function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName, hideHeader }: LiveOperationDrawerProps) { - useEffect(() => { - if (!open) return undefined; - const previous = document.body.style.overflow; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - document.body.style.overflow = "hidden"; - document.addEventListener("keydown", handleKeyDown); - return () => { - document.body.style.overflow = previous; - document.removeEventListener("keydown", handleKeyDown); - }; - }, [onClose, open]); - - if (!open) return null; - return ( -
- -
- ); -} - -interface ServerManagementTerminalDrawerProps { - open: boolean; - serverId: string; - serverName: string; - pluginId: string; - canManage: boolean; - onClose: () => void; -} - -export function ServerManagementTerminalDrawer({ open, serverId, serverName, pluginId, canManage, onClose }: ServerManagementTerminalDrawerProps) { - const [command, setCommand] = useState(""); - const [pending, setPending] = useState(false); - const [lines, setLines] = useState([]); - const [streams, setStreams] = useState>({ status: "loading" }); - const [commandHistory, setCommandHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(null); - const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); - const [followLatest, setFollowLatest] = useState(true); - const outputRef = useRef(null); - const followLatestRef = useRef(true); - const initialHistoryPendingRef = useRef(false); - const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); - const supportsCommands = quickCommands.length > 0; - - const appendLines = useCallback((incoming: TerminalLine[]) => { - if (incoming.length === 0) return; - setLines((current) => mergeTerminalLines(current, incoming)); - }, []); - - const lockTerminalFollow = useCallback(() => { - followLatestRef.current = true; - setFollowLatest(true); - window.requestAnimationFrame(() => { - const output = outputRef.current; - if (output) output.scrollTop = output.scrollHeight; - window.requestAnimationFrame(() => { - const output = outputRef.current; - if (output) output.scrollTop = output.scrollHeight; - initialHistoryPendingRef.current = false; - }); - }); - }, []); - - useEffect(() => { - if (!open) return; - setStreams({ status: "loading" }); - setCommand(""); - setPending(false); - setResult(null); - setHistoryIndex(null); - initialHistoryPendingRef.current = true; - followLatestRef.current = true; - setFollowLatest(true); - setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]); - }, [open, supportsCommands]); - - useEffect(() => { - if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined; - const frame = window.requestAnimationFrame(() => { - const output = outputRef.current; - if (output) output.scrollTop = output.scrollHeight; - }); - return () => window.cancelAnimationFrame(frame); - }, [lines, open]); - - useEffect(() => { - if (!open) return undefined; - let ready = false; - const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow }); - events.addEventListener("stream", (event) => { - const stream = parseLogStreamEvent(event); - if (!stream) return; - ready = true; - setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) })); - }); - events.addEventListener("ready", () => { - ready = true; - setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] }); - lockTerminalFollow(); - }); - events.addEventListener("log", (event) => { - const payload = parseServerLogEvent(event); - if (!payload) return; - ready = true; - const stream = streamFromServerLogEvent(payload); - setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) })); - appendLines([terminalLineFromLog(stream, payload.entry)]); - }); - events.onerror = () => { - if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" }); - }; - return () => events.close(); - }, [appendLines, lockTerminalFollow, open, serverId]); - - function selectQuickCommand(item: TerminalQuickCommand) { - setCommand(item.command); - setHistoryIndex(null); - } - - function handleCommandKeyDown(event: ReactKeyboardEvent) { - if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; - if (commandHistory.length === 0) return; - event.preventDefault(); - const lastIndex = commandHistory.length - 1; - if (event.key === "ArrowUp") { - const nextIndex = historyIndex === null ? lastIndex : Math.max(0, historyIndex - 1); - setHistoryIndex(nextIndex); - setCommand(commandHistory[nextIndex] ?? ""); - return; - } - if (historyIndex === null) return; - const nextIndex = historyIndex + 1; - if (nextIndex > lastIndex) { - setHistoryIndex(null); - setCommand(""); - return; - } - setHistoryIndex(nextIndex); - setCommand(commandHistory[nextIndex] ?? ""); - } - - function clearTerminalBuffer() { - setLines([]); - } - - function handleTerminalScroll() { - const output = outputRef.current; - if (!output || initialHistoryPendingRef.current) return; - const nextFollowLatest = output.scrollHeight - output.clientHeight - output.scrollTop <= 24; - followLatestRef.current = nextFollowLatest; - setFollowLatest(nextFollowLatest); - } - - async function submitCommand(event: FormEvent) { - event.preventDefault(); - if (!supportsCommands || !canManage || pending || !command.trim()) return; - const submitted = command.trim(); - setCommand(""); - setHistoryIndex(null); - setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50)); - setPending(true); - setResult({ status: "pending", label: "正在提交命令" }); - appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]); - try { - const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted)); - const label = bridgeCommandDispatchLabel(response.state, response.id); - setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` }); - appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]); - const finalCommand = await waitForBridgeCommandTerminal(response.id); - if (finalCommand) { - const outcome = terminalLineFromBridgeCommand(finalCommand); - setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text }); - appendLines([outcome]); - } else { - const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`); - setResult({ status: "pending", label: "等待 Run 返回结果" }); - appendLines([timeoutLine]); - } - } catch (error) { - const label = error instanceof Error ? error.message : "命令提交失败"; - setResult({ status: "failed", label }); - appendLines([terminalSystemLine("error", label, "ERROR")]); - } finally { - setPending(false); - } - } - - async function waitForBridgeCommandTerminal(commandId: string): Promise { - for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) { - const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId); - if (isTerminalBridgeCommandState(current.state)) return current; - await delay(terminalBridgeResultPollMs); - } - return null; - } - - return ( - -
-
-
- {serverName} - 当前服务器 Run 日志 · 已接受历史 + SSE 实时推送 · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"} -
-
- - -
-
-
- {streams.status === "error" &&
LOGS{streams.reason}
} - {streams.status === "ready" && streams.data.length === 0 &&
LOGS暂无已接受日志。Run 恢复连接并完成日志水位校准后,新输出会继续追加。
} - {lines.map((line) =>
{line.streamKey || line.level || "LOG"}{line.text}
)} -
-
-
-
- {quickCommands.length > 0 ? "插件快捷指令" : "命令输入"} - {result && } -
- {quickCommands.length > 0 &&
- {quickCommands.map((item) => )} -
} - {!supportsCommands && } title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />} - {supportsCommands && ( -
void submitCommand(event)}> - - -
- )} -
-
- ); -} - -function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { - return terminalQuickCommandCatalog[pluginId] ?? []; -} - -function bridgeCommandDispatchLabel(state: string, commandId: string): string { - return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`; -} - -function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine { - return { - id: `log-${stream.id}-${entry.seq}`, - tone: terminalTone(entry), - text: entry.line, - at: new Date(entry.timestamp).toLocaleTimeString(), - sortKey: Date.parse(entry.timestamp) || Date.now(), - streamKey: stream.streamKey || stream.source, - level: entry.level, - seq: entry.seq - }; -} - -function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine { - const now = Date.now(); - return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey }; -} - -function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean { - return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown"; -} - -function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine { - const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state); - const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt; - const sortKey = Date.parse(completed) || Date.now(); - const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn"; - return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" }; -} - -function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string { - switch (state) { - case "succeeded": return "已成功"; - case "failed": return "已失败"; - case "cancelled": return "已取消"; - case "expired": return "已过期"; - case "unknown": return "状态未知"; - case "claimed": return "Run 已领取"; - case "pending": return "已排队"; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -function terminalTone(entry: LogEntryBody): TerminalLine["tone"] { - const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase(); - if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error"; - if (/\b(warn|warning|timeout|retry)\b/.test(value)) return "warn"; - if (/\b(success|succeeded|ready|started|online|listening|accepted)\b/.test(value)) return "success"; - return "info"; -} - -function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] { - const seen = new Set(current.map((line) => line.id)); - const merged = [...current]; - for (const line of incoming) { - if (seen.has(line.id)) continue; - seen.add(line.id); - merged.push(line); - } - return merged.sort(compareTerminalLines).slice(-maxTerminalLines); -} - -function compareTerminalLines(a: TerminalLine, b: TerminalLine): number { - return a.sortKey - b.sortKey || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id); -} diff --git a/platform_web/components/SourceRCONCommandPanel.test.tsx b/platform_web/components/SourceRCONCommandPanel.test.tsx deleted file mode 100644 index 73e362c..0000000 --- a/platform_web/components/SourceRCONCommandPanel.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw"; - -describe("SourceRCONCommandPanel", () => { - it("uses protected bridge dispatch without confirmation, transcript, or connection fields", () => { - expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand"); - expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest"); - expect(sourceRCONCommandPanelSource).toContain("scumAnnouncementCommand"); - expect(sourceRCONCommandPanelSource).not.toContain("sendSourceRCONCommand"); - expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog"); - expect(sourceRCONCommandPanelSource).not.toContain("operations."); - expect(sourceRCONCommandPanelSource).not.toContain("transcript"); - expect(sourceRCONCommandPanelSource).not.toContain("history"); - expect(sourceRCONCommandPanelSource).not.toContain("password"); - expect(sourceRCONCommandPanelSource).not.toContain("host"); - }); -}); diff --git a/platform_web/components/SourceRCONCommandPanel.tsx b/platform_web/components/SourceRCONCommandPanel.tsx deleted file mode 100644 index 26b9056..0000000 --- a/platform_web/components/SourceRCONCommandPanel.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { type FormEvent, useState } from "react"; - -import { platformApiClient } from "../api/client"; -import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon"; -import { ResultBadge } from "./StateViews"; - -interface SourceRCONCommandPanelProps { - serverId: string; - pluginId: string; -} - -type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null; - -export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) { - const [announcement, setAnnouncement] = useState(""); - const [rawCommand, setRawCommand] = useState(""); - const [pending, setPending] = useState<"announcement" | "command" | null>(null); - const [dispatch, setDispatch] = useState(null); - - if (pluginId !== "game.scum") { - return null; - } - - async function sendAnnouncement(event: FormEvent) { - event.preventDefault(); - setPending("announcement"); - setDispatch({ status: "pending", label: "正在提交服务器公告" }); - try { - const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, scumAnnouncementCommand(announcement))); - setAnnouncement(""); - setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) }); - } catch (error) { - setDispatch({ status: "failed", label: error instanceof Error ? error.message : "服务器公告提交失败" }); - } finally { - setPending(null); - } - } - - async function sendRawCommand(event: FormEvent) { - event.preventDefault(); - setPending("command"); - setDispatch({ status: "pending", label: "正在提交原始管理员指令" }); - try { - const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, rawCommand)); - setRawCommand(""); - setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) }); - } catch (error) { - setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" }); - } finally { - setPending(null); - } - } - - return ( -
-
-
-

SCUM 公告与管理员指令

-

通过插件声明的 protected RCON 通道派发;只显示安全状态,不保留指令原文。

-
- {dispatch && } -
-
-
-

发送公告

-
void sendAnnouncement(event)}> -