功能修改
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-18
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The console already has five required first-party areas, typed Platform API clients, role-scoped routes, long-running job polling, operation tracking, and the black-mecha/magical-girl theme system. The remaining problem is cohesion and truthfulness: the overview hides some API failures as empty collections, page mutations use inconsistent busy/confirm/retry behavior, and operators lack a compact cross-page view of active and failed work.
|
||||||
|
|
||||||
|
This change stays inside `platform_web/` and consumes existing Platform API projections. It does not make the browser a source of truth for jobs, resource state, permissions, or secrets.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Make loading, empty, unavailable, stale, active, succeeded, and failed states visually and semantically distinct.
|
||||||
|
- Give operators an actionable overview of server health, Run endpoint safe status, active/failed jobs, AI provider availability, resource pressure, and recent audit signals.
|
||||||
|
- Standardize mutation behavior around permission gating, confirmation where state is disruptive, action-level busy state, persisted response handling, and retry after failure.
|
||||||
|
- Keep first-party list pages full-width and dense while retaining modal/detail-route workflows.
|
||||||
|
- Preserve the theme and responsive contracts at desktop and 390px.
|
||||||
|
- Recover narrow-screen vertical space by moving the existing ordered text navigation into an off-canvas sidebar with a small theme-aware left-edge handle and bounded swipe gesture.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- No new Platform or Run endpoint, persistence model, permission, or job state machine.
|
||||||
|
- No raw secret/key/token/path/PID/socket/credential/DSN/RCON projection.
|
||||||
|
- No billing, cloud hosting sales, provider marketplace, fleet orchestration, arbitrary shell, or direct browser-to-Run channel.
|
||||||
|
- No route, sidebar order, theme palette, or global particle architecture replacement.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: Aggregate existing safe APIs in the browser without inventing platform health
|
||||||
|
|
||||||
|
The overview will fetch server instances, safe Run endpoint projections, jobs, metrics, platform usage, AI providers, and audit events as independent modules. Each module keeps its own state and refresh timestamp. Failed modules remain visibly unavailable and are excluded from healthy/empty conclusions.
|
||||||
|
|
||||||
|
Alternative considered: add one new dashboard API. Rejected because current APIs already provide the bounded projections needed, and this change does not need a new backend contract.
|
||||||
|
|
||||||
|
### Decision 2: Derive operational summaries in shared pure contracts
|
||||||
|
|
||||||
|
Job buckets, attention signals, server list sorting/filtering, provider summaries, and safe operation labels will live outside page components. Pages render derived view contracts and tests can lock classification without constructing full page trees.
|
||||||
|
|
||||||
|
Alternative considered: calculate every summary inline. Rejected because the same terminal/active/failure classification is needed by the overview, server page, and global operation tray.
|
||||||
|
|
||||||
|
### Decision 3: Platform responses remain authoritative
|
||||||
|
|
||||||
|
Mutations only update resource state from a successful Platform response or a subsequent refresh. Long-running operations use real job IDs and job polling. A rejected request keeps the previous resource state, preserves the failure message, and exposes retry where the same request remains valid.
|
||||||
|
|
||||||
|
Alternative considered: optimistic state transitions. Rejected because plugin/provider/server operations can be rejected by ownership, lifecycle, dependency, or reference constraints.
|
||||||
|
|
||||||
|
### Decision 4: Disruptive state changes use a shared confirmation contract
|
||||||
|
|
||||||
|
Plugin install/enable/disable, AI provider enable/disable/retire, user deactivation, and server destructive actions require resource-specific confirmation. Dialogs close on Escape or cancellation, preserve focus behavior, and disable duplicate submission while busy.
|
||||||
|
|
||||||
|
Alternative considered: confirm only destructive deletes. Rejected because enable/disable and install actions can interrupt running capabilities or create durable work.
|
||||||
|
|
||||||
|
### Decision 5: Capabilities control commands, routes control discovery
|
||||||
|
|
||||||
|
The existing route capability mapping remains the navigation authority. Within an allowed page, mutation controls check the matching session capability and render a clear read-only state when missing. The browser does not elevate access and still handles Platform 401/403 as authoritative rejection.
|
||||||
|
|
||||||
|
Alternative considered: hide every unauthorized control. Rejected because operators benefit from understanding why a page is read-only; safety-critical commands remain unavailable.
|
||||||
|
|
||||||
|
### Decision 6: Extend the existing theme system with shared operational primitives
|
||||||
|
|
||||||
|
New pulse rows, module status headers, compact job rows, and the operation tray use shared classes appended to `theme/base.css`, `var(...)` tokens, existing radius limits, and theme-specific material variables. Global decoration remains exclusively in `MagicalParticleLayer`.
|
||||||
|
|
||||||
|
Alternative considered: page-local cards and effects. Rejected because they would fragment theme switching and violate the repository style contract.
|
||||||
|
|
||||||
|
### Decision 7: Use an off-canvas text sidebar on narrow screens
|
||||||
|
|
||||||
|
At 760px and below, the sidebar leaves normal document flow and stays closed until the operator activates the left-edge handle or completes a rightward swipe that begins at the viewport edge. The drawer preserves the desktop route order, icons, Chinese labels, account access, and session operations; backdrop click, Escape, route selection, and a leftward drawer swipe close it.
|
||||||
|
|
||||||
|
Alternative considered: keep the horizontal icon rail and account strip above every page. Rejected because it consumes scarce vertical space, hides route labels, and makes repeated mobile operations slower to scan.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] Independent overview requests can complete out of order. Mitigation: refresh callbacks update only their module and use explicit loading/ready/error states.
|
||||||
|
- [Risk] More status rows can overload narrow layouts. Mitigation: collapse multi-column regions to a single ordered flow at 390px and keep command rows bounded.
|
||||||
|
- [Risk] Horizontal content gestures can open navigation accidentally. Mitigation: require the opening swipe to start within the left 28px edge and travel at least 56px, while drawer-closing swipes use the inverse threshold.
|
||||||
|
- [Risk] In-session operation history is not durable. Mitigation: label it as current-session request feedback; durable job and audit history continues to come from Platform APIs.
|
||||||
|
- [Risk] Existing item 8 edits overlap `base.css` and API types. Mitigation: reuse its current worktree state, append narrowly scoped classes, and avoid reverting or rewriting lifecycle code.
|
||||||
|
- [Risk] Action confirmation can add friction. Mitigation: require it only for persistent or disruptive operations; refresh and navigation remain immediate.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Add shared derived contracts and tests.
|
||||||
|
2. Add shared operation tray and accessible dialog behavior.
|
||||||
|
3. Update each first-party page while keeping existing API methods and routes.
|
||||||
|
4. Add shared theme styles and responsive rules.
|
||||||
|
5. Run frontend tests/typecheck/build, structure validation, strict OpenSpec validation, and browser acceptance in both themes and at 390px.
|
||||||
|
|
||||||
|
Rollback is frontend-only: remove the new shared components/contracts and restore the previous page render paths. No persisted data migration is required.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Resolved: this change uses existing APIs and does not add a dashboard endpoint.
|
||||||
|
- Resolved: the operation tray is session-scoped feedback; Platform jobs and audit events remain durable truth.
|
||||||
|
- Resolved: development fixtures may remain in tests, but normal page rendering does not silently substitute them for failed APIs.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The first-party console has real management APIs and complete resource workflows, but operators still have to infer platform health across pages, some API failures are rendered as empty success states, and several state-changing actions lack a consistent confirm, progress, and recovery cycle. The next console iteration must turn the existing pages into a cohesive operations workspace without weakening permission or secret boundaries.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a permission-aware operations overview that distinguishes live, delayed, failed, empty, and unavailable API modules and surfaces active or failed jobs, endpoint health, resource pressure, and actionable navigation.
|
||||||
|
- Deepen server management with explicit telemetry availability, job-state summaries, deterministic refresh behavior, and recovery-oriented feedback while retaining real job polling for long-running work.
|
||||||
|
- Deepen plugin marketplace operations with permission gating, confirmation for state changes, persisted-response refresh, action progress, and retryable failures.
|
||||||
|
- Remove normal-page user fixture fallbacks and add API-backed search, role/status filtering, operational summaries, retry, and mutation feedback.
|
||||||
|
- Deepen AI provider management with retryable loading, action-level busy states, confirmation for enable/disable/retire operations, saved-response refresh, and safe configuration presence rather than raw key disclosure.
|
||||||
|
- Add a compact global operation tray for in-session API mutations, including pending, succeeded, and failed records with diagnostic context and page-safe target labels.
|
||||||
|
- Replace the narrow-screen top navigation stack with a theme-aware off-canvas sidebar that keeps Chinese labels visible, opens from a bounded left-edge control or rightward edge swipe, and closes without consuming page height.
|
||||||
|
- Preserve the existing black-mecha and magical-girl crystal-moonlight theme system, shared surfaces, route order, and `MagicalParticleLayer` ownership.
|
||||||
|
- Keep browser and plugin projections free of raw keys, tokens, secret values, host paths, PIDs, sockets, credentials, DSNs, RCON data, and direct Run endpoints.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `platform-operations-console-enrichment`: Permission-aware, API-backed operations views and mutation recovery workflows across overview, servers, plugins, users, and AI providers.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects `platform_web/` contracts, shared components, operation tracking, first-party pages, theme styles, tests, and browser acceptance coverage.
|
||||||
|
- Uses existing Platform APIs for server instances, Run endpoint safe projections, jobs, metrics, audit events, plugins, users, and AI providers; no new direct Run or secret-bearing browser contract is introduced.
|
||||||
|
- Does not add billing, cloud host sales, provider marketplaces, arbitrary remote execution, or unrelated SaaS features.
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Permission-aware operations overview
|
||||||
|
The console SHALL provide an overview that derives server, Run endpoint, job, resource, AI provider, and audit summaries from Platform API responses permitted for the active session.
|
||||||
|
|
||||||
|
#### Scenario: Operator opens overview with available APIs
|
||||||
|
- **WHEN** the active session can read the overview and the required Platform APIs return data
|
||||||
|
- **THEN** the console shows real counts, resource measurements, active and failed job summaries, endpoint health, and actionable signals derived from those responses
|
||||||
|
|
||||||
|
#### Scenario: Session lacks a management capability
|
||||||
|
- **WHEN** a session can view an operational resource but lacks the capability required to mutate it
|
||||||
|
- **THEN** the console presents a read-only explanation and does not dispatch the mutation
|
||||||
|
|
||||||
|
### Requirement: Independent module truth and recovery
|
||||||
|
Each overview and management data module MUST distinguish loading, ready-empty, ready-data, and unavailable states, and MUST provide scoped retry without treating an API failure as an empty or healthy result.
|
||||||
|
|
||||||
|
#### Scenario: Audit API fails while core overview succeeds
|
||||||
|
- **WHEN** server and job APIs succeed but the audit API fails
|
||||||
|
- **THEN** core health remains visible, audit signals are marked unavailable, and the page does not claim that there are no recent audit signals
|
||||||
|
|
||||||
|
#### Scenario: Failed module is retried
|
||||||
|
- **WHEN** an operator retries an unavailable module and its API succeeds
|
||||||
|
- **THEN** only that module transitions to ready data and its refresh timestamp is updated
|
||||||
|
|
||||||
|
### Requirement: Real job and mutation progress
|
||||||
|
Long-running work MUST use Platform job identifiers and job states, while immediate mutations MUST remain pending until the Platform response returns and MUST never use timers or optimistic local success.
|
||||||
|
|
||||||
|
#### Scenario: Long-running server operation is queued
|
||||||
|
- **WHEN** a server operation returns a job identifier
|
||||||
|
- **THEN** the console polls the Platform job projection and shows queued, claimed, running, retrying, terminal success, or terminal failure from that projection
|
||||||
|
|
||||||
|
#### Scenario: Mutation is rejected
|
||||||
|
- **WHEN** the Platform rejects a plugin, user, server, or AI provider mutation
|
||||||
|
- **THEN** the previous resource state remains visible and the console shows the rejection with a retry or corrective action when applicable
|
||||||
|
|
||||||
|
### Requirement: Confirmed disruptive operations
|
||||||
|
Persistent or disruptive resource state changes MUST require resource-specific confirmation and MUST prevent duplicate submission while a request is pending.
|
||||||
|
|
||||||
|
#### Scenario: Plugin state change is confirmed
|
||||||
|
- **WHEN** an authorized operator chooses install, enable, or disable and confirms the named plugin action
|
||||||
|
- **THEN** the console dispatches one Platform request, disables duplicate confirmation, and refreshes state from the persisted response
|
||||||
|
|
||||||
|
#### Scenario: Confirmation is cancelled
|
||||||
|
- **WHEN** an operator cancels a confirmation or presses Escape before submission
|
||||||
|
- **THEN** no mutation is dispatched and the current resource state is preserved
|
||||||
|
|
||||||
|
### Requirement: Server operations workspace
|
||||||
|
Server management SHALL expose API telemetry availability, deterministic search/filter/sort, active and failed job counts, and recovery-oriented navigation without covering or reflowing server-card operational data.
|
||||||
|
|
||||||
|
#### Scenario: Metrics are unavailable
|
||||||
|
- **WHEN** the server list succeeds but metrics loading fails
|
||||||
|
- **THEN** server cards remain available, metrics are labeled unavailable rather than zero, and a scoped metrics retry is offered
|
||||||
|
|
||||||
|
#### Scenario: Server has active or failed jobs
|
||||||
|
- **WHEN** Platform jobs reference a visible server
|
||||||
|
- **THEN** the server working surface shows the real active or failed job count and lets the operator open the server detail or job progress workflow
|
||||||
|
|
||||||
|
### Requirement: Plugin marketplace operations
|
||||||
|
The plugin marketplace SHALL use Platform list/detail/state responses, permission-gated commands, confirmation, action progress, and failure recovery without production fallback data.
|
||||||
|
|
||||||
|
#### Scenario: Plugin state response succeeds
|
||||||
|
- **WHEN** the Platform accepts a confirmed plugin state action
|
||||||
|
- **THEN** list and detail views update from the returned or refreshed Platform plugin state and the action result is traceable
|
||||||
|
|
||||||
|
#### Scenario: Plugin detail refresh fails
|
||||||
|
- **WHEN** the selected plugin summary exists but its detail API fails
|
||||||
|
- **THEN** the summary remains visible, detail is marked unavailable, and the operator can retry detail loading without reloading the page
|
||||||
|
|
||||||
|
### Requirement: User management operations
|
||||||
|
User management SHALL render only Platform API data in normal operation and SHALL provide search, role/status filtering, create/edit/status/deactivate feedback, and API-scoped retry.
|
||||||
|
|
||||||
|
#### Scenario: User API fails in development
|
||||||
|
- **WHEN** the normal user list API fails in a development build
|
||||||
|
- **THEN** the page shows an unavailable state and does not replace the result with actionable sample users
|
||||||
|
|
||||||
|
#### Scenario: User filters are applied
|
||||||
|
- **WHEN** an authorized operator enters a query or selects role/status filters
|
||||||
|
- **THEN** the full-width user working surface shows only matching Platform users and keeps create/edit flows in dialogs
|
||||||
|
|
||||||
|
### Requirement: AI provider operations
|
||||||
|
AI provider management SHALL provide scoped list retry, action-level busy states, confirmed status/retirement changes, persisted-response refresh, and safe key configuration presence.
|
||||||
|
|
||||||
|
#### Scenario: Provider load is retried
|
||||||
|
- **WHEN** the provider list fails and the operator retries
|
||||||
|
- **THEN** the page calls the provider list API again without a full browser reload and renders the returned state
|
||||||
|
|
||||||
|
#### Scenario: Provider status action is pending
|
||||||
|
- **WHEN** an authorized operator confirms enable, disable, or retire
|
||||||
|
- **THEN** only that provider action is disabled, duplicate submission is prevented, and success is shown only after a Platform response
|
||||||
|
|
||||||
|
### Requirement: Session operation tray
|
||||||
|
The console SHALL expose a compact, theme-aware tray for current-session mutation records while clearly distinguishing it from durable Platform job and audit history.
|
||||||
|
|
||||||
|
#### Scenario: Operation fails
|
||||||
|
- **WHEN** a tracked mutation fails
|
||||||
|
- **THEN** the tray shows the intent, safe target label, failure reason, and diagnostic identifier without exposing forbidden values
|
||||||
|
|
||||||
|
#### Scenario: No session operations exist
|
||||||
|
- **WHEN** no mutation has been attempted in the current session
|
||||||
|
- **THEN** the tray remains compact and communicates that durable job and audit history is available on the relevant operational pages
|
||||||
|
|
||||||
|
### Requirement: Theme, responsive, and secret safety
|
||||||
|
All new operations surfaces MUST use existing theme tokens and shared styles, MUST remain coherent in black-mecha and magical-girl themes at desktop and 390px, and MUST not render raw keys, tokens, secret values, host paths, PIDs, sockets, credentials, DSNs, RCON data, or direct Run endpoints.
|
||||||
|
|
||||||
|
#### Scenario: Theme changes
|
||||||
|
- **WHEN** an operator switches between black-mecha and magical-girl themes
|
||||||
|
- **THEN** overview modules, lists, confirmations, progress rows, and the operation tray update atomically through existing theme variables without page-local global decoration
|
||||||
|
|
||||||
|
#### Scenario: Narrow viewport renders operations surfaces
|
||||||
|
- **WHEN** the console is rendered at 390px width
|
||||||
|
- **THEN** controls and text remain within their containers, full-width working surfaces collapse deliberately, and no operational status or action is obscured
|
||||||
|
|
||||||
|
#### Scenario: Narrow viewport navigation stays out of page flow
|
||||||
|
- **WHEN** the console is rendered at 390px width with navigation closed
|
||||||
|
- **THEN** the sidebar, account strip, and icon rail do not consume page height, and a small theme-aware left-edge menu control remains available
|
||||||
|
|
||||||
|
#### Scenario: Operator opens and closes narrow navigation
|
||||||
|
- **WHEN** the operator activates the left-edge control or swipes right from within 28px of the viewport edge
|
||||||
|
- **THEN** an off-canvas vertical menu opens with icons and readable Chinese route labels in the existing order
|
||||||
|
- **AND** Escape, backdrop activation, route selection, or a leftward drawer swipe closes the menu
|
||||||
|
|
||||||
|
#### Scenario: Rendered output is scanned for forbidden fragments
|
||||||
|
- **WHEN** first-party pages are rendered with API-backed fixtures during verification
|
||||||
|
- **THEN** forbidden raw secret, path, process, socket, credential, DSN, RCON, and direct Run endpoint fragments are absent
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
## 1. Shared Operations Contracts
|
||||||
|
|
||||||
|
- [x] 1.1 Add shared frontend view contracts for module availability, job buckets, attention signals, and safe operation targets outside page components.
|
||||||
|
- [x] 1.2 Add pure derivation helpers for active/failed jobs, server attention sorting, and overview module summaries.
|
||||||
|
- [x] 1.3 Add focused tests for job classification, unavailable-module handling, deterministic ordering, and forbidden-field omission.
|
||||||
|
|
||||||
|
## 2. Shared Operations UI
|
||||||
|
|
||||||
|
- [x] 2.1 Add a compact theme-aware current-session operation tray with pending, succeeded, failed, and empty states.
|
||||||
|
- [x] 2.2 Pass the operation tracker through the shell and keep durable Platform job/audit history clearly distinguished from session feedback.
|
||||||
|
- [x] 2.3 Add Escape handling, duplicate-submit protection, and accessible labels to shared management and confirmation dialogs.
|
||||||
|
|
||||||
|
## 3. Operations Overview
|
||||||
|
|
||||||
|
- [x] 3.1 Refactor overview API modules so metrics, usage, providers, and audit events preserve independent loading, ready, empty, and unavailable states.
|
||||||
|
- [x] 3.2 Add actionable endpoint/job/resource/provider status modules and scoped retry/refresh timestamps using only safe Platform projections.
|
||||||
|
- [x] 3.3 Add overview tests covering partial API failure, active/failed jobs, actionable navigation, and read-only session behavior.
|
||||||
|
|
||||||
|
## 4. Server Management
|
||||||
|
|
||||||
|
- [x] 4.1 Add explicit metrics availability/retry and deterministic status/job sorting to the full-width server working surface.
|
||||||
|
- [x] 4.2 Add real active/failed job summaries and recovery navigation without reflowing cards or simulating terminal success.
|
||||||
|
- [x] 4.3 Add tests for metrics failure, job summaries, sorting, and permission-aware create/action controls.
|
||||||
|
|
||||||
|
## 5. Plugin Marketplace
|
||||||
|
|
||||||
|
- [x] 5.1 Add permission-gated confirmed install/enable/disable actions with per-action busy state and persisted-response refresh.
|
||||||
|
- [x] 5.2 Add scoped plugin detail retry that preserves the list summary when detail loading fails.
|
||||||
|
- [x] 5.3 Add tests for confirmation cancellation, duplicate-submit prevention, state failure recovery, and detail retry.
|
||||||
|
|
||||||
|
## 6. User Management
|
||||||
|
|
||||||
|
- [x] 6.1 Remove normal-page development user fallback substitution and render API failure with scoped retry.
|
||||||
|
- [x] 6.2 Add API-backed search, role/status filters, operational summaries, and clear-filter behavior to the full-width user list.
|
||||||
|
- [x] 6.3 Add tests for development API failure, filtering, mutation failure preservation, and platform-admin gating.
|
||||||
|
|
||||||
|
## 7. AI Provider Management
|
||||||
|
|
||||||
|
- [x] 7.1 Replace full-page reload retry with scoped provider loading and preserve explicit empty versus unavailable states.
|
||||||
|
- [x] 7.2 Add confirmed enable/disable/retire actions, provider-scoped busy state, persisted-response refresh, and safe key-presence copy.
|
||||||
|
- [x] 7.3 Add tests for scoped retry, confirmation cancellation, pending duplicate prevention, and failed action recovery.
|
||||||
|
|
||||||
|
## 8. Theme and Responsive Integration
|
||||||
|
|
||||||
|
- [x] 8.1 Add shared theme styles for operations modules, job rows, filters, confirmations, and the operation tray using existing tokens and frame materials.
|
||||||
|
- [x] 8.2 Add responsive rules for full-width working surfaces, dialogs, toolbars, and operation rows at 390px without page-local global decoration.
|
||||||
|
- [x] 8.3 Extend CSS contract tests for theme material reuse, radius limits, and nested-frame safety.
|
||||||
|
- [x] 8.4 Replace the narrow-screen top navigation stack with a theme-aware off-canvas text sidebar, left-edge effect handle, bounded swipe-open/close behavior, and accessible dismissal.
|
||||||
|
- [x] 8.5 Add focused component and CSS contract coverage for mobile labels, drawer state semantics, gesture thresholds, and no-flow layout.
|
||||||
|
|
||||||
|
## 9. Verification
|
||||||
|
|
||||||
|
- [x] 9.1 Run `cd platform_web && npm run typecheck`.
|
||||||
|
- [x] 9.2 Run `cd platform_web && npm test`.
|
||||||
|
- [x] 9.3 Run `cd platform_web && npm run build`.
|
||||||
|
- [x] 9.4 Run browser acceptance for all five first-party areas in black-mecha and magical-girl themes at desktop and 390px, including mobile drawer open/close, confirmation, and failure recovery.
|
||||||
|
- [x] 9.5 Scan rendered first-party pages for forbidden raw key, token, secret, path, PID, socket, credential, DSN, RCON, and direct Run endpoint fragments.
|
||||||
|
- [x] 9.6 Run `scripts/check-structure.sh`, `openspec validate enrich-platform-operations-console --strict`, and both repository `git diff --check` commands, then record evidence.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- 2026-07-18 15:25 CST: `npm run typecheck`, `npm run build`, and the full Vitest suite passed (24 files, 132 tests).
|
||||||
|
- In-app browser acceptance passed against the real local Platform stack for 首页、服务器管理、插件市场、用户管理、AI 提供商管理 and server detail in `mecha-black` and `magical-girl` at 1440x960 and 390x844 (24 route/theme/viewport checks, zero horizontal overflow, zero console errors).
|
||||||
|
- The 390px sidebar stayed outside page flow (`mainTop=0`, `scrollWidth=390`), opened as a 300px icon-and-Chinese-text drawer, and closed by route selection; both themes were visually inspected.
|
||||||
|
- Confirmation cancellation preserved plugin state and dispatched no mutation. Real plugin persisted responses populated the session operation tray; a duplicate server ID produced a real Platform failure while preserving the form, and a corrected ID retry succeeded. A real dependency validation failure remained traceable in the staged task dialog.
|
||||||
|
- Rendered-page scans found no raw key/token/secret value, host path, PID, socket, credential, DSN, RCON value, or direct Run endpoint across all 24 route states. Safe ordinary labels such as key presence and non-projection notices were intentionally retained.
|
||||||
|
- `scripts/local-debug-smoke.sh` passed using Run-reported capabilities; absent `distribution.build` was verified as unavailable without fake success. `bash -n` and `node --check platform_web/acceptance/browser-acceptance.mjs` passed.
|
||||||
|
- `scripts/check-structure.sh`, `openspec validate enrich-platform-operations-console --strict`, main repository `git diff --check`, and independent Run repository `git diff --check` all passed.
|
||||||
|
- Structured browser evidence: `/private/tmp/browser-local-debug-acceptance-9k/browser-acceptance/item-9-evidence.json`.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-20
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The current platform already has the foundation for plugin runtime declarations, Client Manager build/deploy/control/update/rollback/uninstall, component-key registration, heartbeat, safe status projections, log ingest, artifact records, config diff review, remote access declarations, and backup metadata. The SCUM example manifest also declares a `custom-client` lifecycle and `game-client.bridge` capability, but the bridge is only a placeholder: there is no durable command domain, snapshot ingest/query model, bridge service, repository, DTO, or API surface.
|
||||||
|
|
||||||
|
The reference SCUM repositories contain useful behavior: a companion client that can interact with the game window and OCR, server operations and command flows, SCUM log parsing, player/squad/vehicle/flag state, backups, restarts, rewards, events, and database-backed queries. They also contain patterns that must not be copied into this platform: shared credentials, insecure TLS skip, arbitrary terminal commands, arbitrary SQL, raw host paths, direct run sockets, direct cloud credentials, and SCUM business rules embedded into executor code.
|
||||||
|
|
||||||
|
The intended ownership model is:
|
||||||
|
|
||||||
|
- `game.scum` plugin owns SCUM semantics: pages, schemas, commands, players, squads, vehicles, flags, logs, database query templates, backup/restart/event policy, and `scum_client` companion behavior.
|
||||||
|
- Platform owns generic product infrastructure: auth, permission checks, durable queue, scheduling, audit, persistence, safe projections, and plugin-page APIs.
|
||||||
|
- Independent Run owns generic privileged execution only: process supervision, bounded file/SQLite/archive operations, and artifact transfer. No SCUM-specific source tree is added to this repository.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Introduce a generic Game Client Bridge that supports authenticated companion clients, durable commands, command claims, acknowledgements, results, cancellation, expiry, idempotency, fencing, and typed snapshot ingestion/query.
|
||||||
|
- Add SCUM plugin operations that declare commands, snapshots, permissions, approval levels, page contracts, and companion-client expectations.
|
||||||
|
- Align the SCUM plugin manifest and the real `scum_client` model so generated packages use Platform Client Manager registration/session and bridge APIs instead of legacy `/api/v1/scum-clients/*` shared-token endpoints.
|
||||||
|
- Preserve channel isolation: game-client bridge traffic does not block run control, jobs, log ingest, or artifact transfer.
|
||||||
|
- Keep raw credentials, host paths, SQL text, direct sockets, and component secrets out of plugin pages and browser-visible DTOs.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Do not implement SCUM business logic inside independent Run.
|
||||||
|
- Do not add a `run/` source tree back to this repository.
|
||||||
|
- Do not expose arbitrary shell, arbitrary SQL, generic remote desktop, direct cloud storage credentials, QQ/SMS/music/Douyin/provider workflows, host sales, billing, or unrelated SaaS marketplace features.
|
||||||
|
- Do not auto-ban or auto-punish players from heuristics in the first bridge phase; moderation begins as evidence, alerts, and operator-approved actions.
|
||||||
|
- Do not require all legacy SCUM robot features to ship in one implementation pass.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision: Separate Game Client Bridge from Run lifecycle channels
|
||||||
|
|
||||||
|
Platform will add a separate bridge domain and API for companion-client commands and snapshots. It will not reuse Run job leases, log batches, artifact chunks, or lifecycle control endpoints for game-window operations.
|
||||||
|
|
||||||
|
Rationale: the existing run-contracts already state that the optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport. Keeping it separate prevents game commands or large snapshots from delaying heartbeat, job acknowledgement, log upload, or artifact transfer.
|
||||||
|
|
||||||
|
Alternative considered: model every companion action as a Run job. That would blur operator-visible game operations with machine-side lifecycle execution, make SCUM commands look like generic privileged jobs, and increase the risk of leaking host/executor details into plugin pages.
|
||||||
|
|
||||||
|
### Decision: Reuse Client Manager identity for companion clients
|
||||||
|
|
||||||
|
`scum_client` will be deployed and supervised through the existing Client Manager lifecycle. Bridge APIs will require a valid component session bound to server instance, plugin, profile key, artifact, key generation, deployment generation, capabilities, and expiry.
|
||||||
|
|
||||||
|
Rationale: the platform already has component-key generation, registration signatures, session revocation, heartbeat, health projection, and staged update/rollback. Extending from that identity avoids introducing a second secret model.
|
||||||
|
|
||||||
|
Alternative considered: preserve legacy shared `SCUMClientCredential` and `/api/v1/scum-clients/hello|heartbeat|commands|results|snapshots`. That pattern cannot meet current security boundaries because one shared credential can outlive deployment generations, cannot fence stale packages cleanly, and was paired with insecure TLS behavior in the reference client.
|
||||||
|
|
||||||
|
### Decision: Let plugins declare SCUM semantics, not Platform or Run
|
||||||
|
|
||||||
|
The `game.scum` plugin will declare command catalog entries, snapshot schemas, query templates, page contracts, permission scopes, and approval levels. Platform will validate and persist these declarations, enforce auth/audit/lifecycle mechanics, and expose safe bridge APIs. Run will only execute generic capabilities requested by Platform.
|
||||||
|
|
||||||
|
Rationale: the user correctly pointed out that these are SCUM plugin features. Putting SCUM semantics in Platform or Run would make the generic layers harder to reuse for other games and would violate the repository boundary that Run stays independent.
|
||||||
|
|
||||||
|
Alternative considered: add first-class SCUM domain services directly to Platform. That would produce faster short-term UI but would freeze SCUM-specific concepts into platform core.
|
||||||
|
|
||||||
|
### Decision: Use typed templates for database and file operations
|
||||||
|
|
||||||
|
SCUM database reads will be exposed as plugin-declared read-only query templates with typed parameters and bounded result schemas. Backups and restart automation will be declared as SCUM logical policies but executed through generic Platform/Run operations.
|
||||||
|
|
||||||
|
Rationale: the reference projects include useful SCUM.db and maintenance behavior, but arbitrary SQL or raw path access is not acceptable in the plugin-page boundary. Template declarations preserve common workflows while keeping operations reviewable and enforceable.
|
||||||
|
|
||||||
|
Alternative considered: expose a generic SQL console or file browser to plugin pages. That would violate the existing platform rule that pages never receive DSNs, credentials, host paths, or direct Run endpoints.
|
||||||
|
|
||||||
|
### Decision: Start moderation as evidence and approval flows
|
||||||
|
|
||||||
|
Duplicate IP, mine, unlock, trade, and suspicious activity signals will initially create typed evidence, alerts, and suggested actions. Destructive or punitive actions require explicit operator approval and audit.
|
||||||
|
|
||||||
|
Rationale: the reference robot contains powerful heuristics, but automatic punishments have high false-positive and abuse risk. Approval-first flows are safer and still make the signals useful.
|
||||||
|
|
||||||
|
Alternative considered: directly port automatic punish/ban behavior. That is out of scope for the first bridge and would require separate governance requirements.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- Bridge queue and snapshot persistence can become broad quickly -> Mitigation: phase implementation around a small generic state machine, bounded payload sizes, schema versioning, retention, and server-scoped query filters.
|
||||||
|
- Real `scum_client` currently reads embedded `config.yaml` and does not parse the manifest's `--config config.json` argument -> Mitigation: update the SCUM plugin declaration and companion bootstrap together; support a generated config format that carries Platform registration settings without raw secrets in browser-visible DTOs.
|
||||||
|
- Snapshot schemas may drift from actual SCUM output -> Mitigation: version schemas, persist raw diagnostic excerpts only in redacted server-side records when needed, and keep plugin validators close to parser code.
|
||||||
|
- Remote read-only SQLite transport currently has gaps around payload/input propagation -> Mitigation: implement bridge database templates only after the remote access request path preserves declared inputs end to end.
|
||||||
|
- Frontend scope could sprawl into the whole legacy admin product -> Mitigation: first pages should cover operationally central SCUM surfaces only: client health, commands, players/sessions, vehicles/squads/flags, semantic logs, and maintenance.
|
||||||
|
- Long-running commands may conflict with deployment updates or session resets -> Mitigation: command claims include component session, deployment generation, lease expiry, and fencing tokens; stale sessions cannot ack or complete current commands.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Add Platform bridge domain, DTOs, repository interfaces, persistence, service methods, validators, routes, and tests.
|
||||||
|
2. Extend plugin manifest/schema validation for game-client command catalogs, snapshot schemas, approval levels, and page bridge contracts.
|
||||||
|
3. Update the SCUM example plugin manifest from local proof placeholders to a real `game.scum` lifecycle/client declaration aligned with generated config and Client Manager registration.
|
||||||
|
4. Add SCUM plugin page/API types and initial UI surfaces using safe Platform projections.
|
||||||
|
5. Adapt `scum_client` packaging expectations so it registers and heartbeats through Client Manager sessions and uses bridge command/snapshot APIs.
|
||||||
|
6. Add typed SCUM log parsing, database query templates, backup/restart declarations, and event/reward flows in later task groups after the bridge is verified.
|
||||||
|
7. Run `openspec validate implement-game-client-bridge-and-scum-operations --strict`, focused backend/frontend/plugin tests, and `scripts/check-structure.sh`.
|
||||||
|
|
||||||
|
Rollback is feature-flag-like by declaration: servers without a deployed compatible Client Manager or without a plugin-declared bridge catalog see disabled bridge actions and no command dispatch. Existing run lifecycle, log ingest, and plugin registry behavior remains intact.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which exact SCUM operations should be enabled in the first UI pass: read-only snapshots plus announcements, or also gifts/rewards/restarts?
|
||||||
|
- Should bridge command payload/result bodies be stored entirely in Platform DB for the first pass, or split larger artifacts to the artifact channel once payloads exceed a small threshold?
|
||||||
|
- Does `scum_client` remain in the external SCUM repository with generated packaging metadata, or should a minimal companion fixture be added under the plugin example for local smoke tests only?
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The SCUM companion client, robot workflows, and run-side helpers contain useful server-operations behavior, but their current shape mixes SCUM product semantics with ad hoc transport, credentials, host paths, and executor responsibilities. We need a first-party bridge that lets `game.scum` own SCUM-specific commands, snapshots, and pages while Platform and Run provide only generic secure infrastructure.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a generic Game Client Bridge capability for durable, authenticated component commands, command lifecycle tracking, cancellation, expiry, idempotency, fencing, result recording, and versioned snapshot ingest/query.
|
||||||
|
- Add SCUM plugin operations on top of that bridge: command catalog, snapshot schemas, page contracts, permissions, and approval levels for players, squads, vehicles, flags, sessions, logs, database query templates, backups, restarts, and events.
|
||||||
|
- Adapt the real `scum_client` model into a plugin-owned companion component that registers through the Platform Client Manager path, heartbeats securely, claims bridge commands, returns results, and uploads typed snapshots.
|
||||||
|
- Keep independent Run free of SCUM business logic. Run may deploy, supervise, execute bounded process/file/SQLite/archive capabilities, and report lifecycle state, but SCUM semantics remain declared by the plugin and mediated by Platform.
|
||||||
|
- Replace unsafe legacy patterns from the reference projects with platform-mediated equivalents: no arbitrary terminal, no arbitrary SQL, no raw host paths, no shared credentials, no direct run sockets, no insecure TLS skip, and no script-based arbitrary URL self-update.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `game-client-bridge`: Defines the platform bridge for authenticated companion clients, durable command queues, command result flow, cancellation/expiry semantics, snapshot ingestion, snapshot querying, retention, and audit trails.
|
||||||
|
- `scum-operations`: Defines the SCUM plugin-owned operational model, including typed commands, typed snapshots, pages, permissions, approval policy, log semantics, database query templates, backup/restart policies, and client integration requirements.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend Platform: new API routes, DTOs, domain types, repositories, services, persistence models, validation rules, permissions, and audit events for the Game Client Bridge.
|
||||||
|
- Platform Web: bridge-aware SCUM operation views that preserve the magical-girl crystal-moonlight console direction and do not receive raw credentials or host paths.
|
||||||
|
- Plugins: `plugins/examples/scum-server-plugin` gains real `game.scum` command catalogs, snapshot schemas, page contracts, permissions, and lifecycle/client declarations aligned with the real companion client.
|
||||||
|
- Companion Client: `scum_client` behavior is migrated from legacy shared-token endpoints to Platform Client Manager registration/session and bridge command/snapshot APIs.
|
||||||
|
- Run Executor: no SCUM-specific source is added to this repository; Run remains an independent generic executor for declared process, file, SQLite, artifact, and packaging operations.
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Component sessions authenticate bridge access
|
||||||
|
|
||||||
|
The Platform SHALL require every Game Client Bridge request from a companion client to use a valid component session issued through the Client Manager registration flow.
|
||||||
|
|
||||||
|
#### Scenario: Valid component session uses bridge capability
|
||||||
|
|
||||||
|
- **WHEN** a deployed companion client registers with the current component key generation, deployment generation, and `game-client.bridge` capability
|
||||||
|
- **THEN** the Platform accepts bridge requests for the bound server instance, plugin, profile key, artifact, and session expiry window
|
||||||
|
|
||||||
|
#### Scenario: Stale component session is rejected
|
||||||
|
|
||||||
|
- **WHEN** a companion client uses a session from a revoked key generation, expired session, old deployment generation, or undeclared bridge capability
|
||||||
|
- **THEN** the Platform rejects the bridge request without returning raw key material or internal secret locations
|
||||||
|
|
||||||
|
### Requirement: Commands are durable and fenced
|
||||||
|
|
||||||
|
The Platform SHALL persist Game Client Bridge commands with lifecycle state, idempotency key, expiry, priority, declared command type, payload reference or inline bounded payload, target server instance, plugin, requester, approval state, claim lease, fencing token, and audit metadata.
|
||||||
|
|
||||||
|
#### Scenario: Operator queues declared bridge command
|
||||||
|
|
||||||
|
- **WHEN** an authorized operator queues a command declared by the active plugin bridge catalog
|
||||||
|
- **THEN** the Platform stores the command as pending, records an audit event, and exposes only safe command status to plugin pages
|
||||||
|
|
||||||
|
#### Scenario: Duplicate idempotency key is reused
|
||||||
|
|
||||||
|
- **WHEN** the same requester submits the same command type with the same idempotency key for the same server instance
|
||||||
|
- **THEN** the Platform returns the existing command instead of creating a duplicate command
|
||||||
|
|
||||||
|
#### Scenario: Expired command is not claimed
|
||||||
|
|
||||||
|
- **WHEN** a pending command has passed its expiry time before a companion client claims it
|
||||||
|
- **THEN** the Platform marks the command expired and prevents later claim or execution
|
||||||
|
|
||||||
|
### Requirement: Companion clients claim and complete commands
|
||||||
|
|
||||||
|
The Platform SHALL let authenticated companion clients claim pending bridge commands in bounded batches and complete them only with the active claim lease and fencing token.
|
||||||
|
|
||||||
|
#### Scenario: Client claims pending command batch
|
||||||
|
|
||||||
|
- **WHEN** an online companion client polls for bridge commands for its bound server instance and profile key
|
||||||
|
- **THEN** the Platform returns only eligible pending commands, marks them claimed, assigns leases, and includes fencing tokens
|
||||||
|
|
||||||
|
#### Scenario: Stale claim cannot complete command
|
||||||
|
|
||||||
|
- **WHEN** a companion client submits an ack or result with an expired lease, stale fencing token, or mismatched component session
|
||||||
|
- **THEN** the Platform rejects the update and leaves the current command state protected from stale completion
|
||||||
|
|
||||||
|
#### Scenario: Command result is recorded
|
||||||
|
|
||||||
|
- **WHEN** the active claimant completes a command with a success or failure result
|
||||||
|
- **THEN** the Platform stores sanitized result metadata, updates command status, records completion time, and emits an audit event
|
||||||
|
|
||||||
|
### Requirement: Operators can cancel pending or claimed commands
|
||||||
|
|
||||||
|
The Platform SHALL allow authorized operators to cancel bridge commands that are not already terminal and SHALL prevent cancelled commands from being executed or completed as successful.
|
||||||
|
|
||||||
|
#### Scenario: Pending command is cancelled
|
||||||
|
|
||||||
|
- **WHEN** an authorized operator cancels a pending command
|
||||||
|
- **THEN** the Platform marks the command cancelled and excludes it from future claim batches
|
||||||
|
|
||||||
|
#### Scenario: Claimed command is cancelled before completion
|
||||||
|
|
||||||
|
- **WHEN** an authorized operator cancels a claimed command
|
||||||
|
- **THEN** the Platform records the cancellation and rejects later success results from the old claim
|
||||||
|
|
||||||
|
### Requirement: Snapshots are versioned and typed
|
||||||
|
|
||||||
|
The Platform SHALL ingest Game Client Bridge snapshots only when their type, schema version, sequence, source component, payload shape, and retention policy match active plugin declarations.
|
||||||
|
|
||||||
|
#### Scenario: Client uploads declared snapshot
|
||||||
|
|
||||||
|
- **WHEN** a companion client uploads a snapshot that matches a declared snapshot type and schema version
|
||||||
|
- **THEN** the Platform stores it with server instance, plugin, profile key, source session, sequence, observed time, and retention metadata
|
||||||
|
|
||||||
|
#### Scenario: Snapshot sequence is stale
|
||||||
|
|
||||||
|
- **WHEN** a companion client uploads a snapshot with a sequence older than or equal to the latest accepted sequence for the same stream
|
||||||
|
- **THEN** the Platform rejects or quarantines the stale snapshot according to validation policy and does not replace the current projection
|
||||||
|
|
||||||
|
#### Scenario: Plugin page queries snapshots
|
||||||
|
|
||||||
|
- **WHEN** an authorized plugin page requests snapshots for an owned server instance
|
||||||
|
- **THEN** the Platform returns bounded safe projections without raw component secrets, host paths, direct sockets, or unbounded raw dumps
|
||||||
|
|
||||||
|
### Requirement: Bridge traffic is isolated from Run channels
|
||||||
|
|
||||||
|
The Platform SHALL keep Game Client Bridge command and snapshot traffic separate from Run control heartbeat, job acknowledgement, log ingest, and artifact transfer channels.
|
||||||
|
|
||||||
|
#### Scenario: Large snapshot ingestion does not block control
|
||||||
|
|
||||||
|
- **WHEN** a companion client uploads a large but allowed snapshot payload
|
||||||
|
- **THEN** Run control heartbeat, job acknowledgement, and log upload remain independently serviceable through their own channels
|
||||||
|
|
||||||
|
#### Scenario: Bridge unavailable does not disable server lifecycle
|
||||||
|
|
||||||
|
- **WHEN** the Game Client Bridge service is unavailable or no companion client is online
|
||||||
|
- **THEN** existing Run lifecycle actions, log ingest, artifact transfer, and Client Manager lifecycle projections continue to operate
|
||||||
|
|
||||||
|
### Requirement: Browser-visible bridge DTOs are safe projections
|
||||||
|
|
||||||
|
The Platform SHALL expose only safe bridge declarations, command statuses, results, snapshots, availability reasons, and audit references to plugin pages.
|
||||||
|
|
||||||
|
#### Scenario: Plugin page loads bridge state
|
||||||
|
|
||||||
|
- **WHEN** a plugin page loads bridge state for a server instance
|
||||||
|
- **THEN** the response excludes raw credentials, component keys, sessions, DSNs, host paths, direct Run endpoints, sockets, and storage provider credentials
|
||||||
|
|
||||||
|
### Requirement: Bridge records are retained and audited
|
||||||
|
|
||||||
|
The Platform SHALL apply bounded retention to bridge commands, results, snapshots, and audit references while preserving enough metadata for operator review and troubleshooting.
|
||||||
|
|
||||||
|
#### Scenario: Retention job expires old bridge records
|
||||||
|
|
||||||
|
- **WHEN** bridge records exceed configured retention limits
|
||||||
|
- **THEN** the Platform expires or compacts old records without exposing deleted payloads through plugin page APIs
|
||||||
|
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: SCUM plugin owns SCUM operation semantics
|
||||||
|
|
||||||
|
The `game.scum` plugin SHALL declare SCUM-specific commands, snapshot schemas, log event schemas, database query templates, permissions, approval levels, page contracts, and policy labels.
|
||||||
|
|
||||||
|
#### Scenario: Platform loads SCUM bridge declarations
|
||||||
|
|
||||||
|
- **WHEN** the Platform registers the SCUM plugin manifest
|
||||||
|
- **THEN** it validates the declared SCUM operation catalog and stores safe declarations without adding SCUM business logic to independent Run
|
||||||
|
|
||||||
|
#### Scenario: Unknown SCUM command is rejected
|
||||||
|
|
||||||
|
- **WHEN** a plugin page or operator attempts to queue a SCUM command that is not declared by the active SCUM plugin catalog
|
||||||
|
- **THEN** the Platform rejects the command before it reaches a companion client
|
||||||
|
|
||||||
|
### Requirement: SCUM companion client uses Platform identity and bridge APIs
|
||||||
|
|
||||||
|
The SCUM companion client SHALL register and heartbeat through the Platform Client Manager component-session flow and SHALL use Game Client Bridge command and snapshot APIs for game-window interaction.
|
||||||
|
|
||||||
|
#### Scenario: Generated client package starts with platform config
|
||||||
|
|
||||||
|
- **WHEN** a SCUM Client Manager package is generated and deployed
|
||||||
|
- **THEN** its runtime configuration aligns with the actual companion client bootstrap and includes only the material required to register with Platform through the current component-key generation
|
||||||
|
|
||||||
|
#### Scenario: Legacy shared-token endpoint is not used
|
||||||
|
|
||||||
|
- **WHEN** a SCUM companion client exchanges commands or snapshots with Platform
|
||||||
|
- **THEN** it does not use legacy shared-token `/api/v1/scum-clients/hello`, `/heartbeat`, `/commands`, `/results`, or `/snapshots` endpoints
|
||||||
|
|
||||||
|
#### Scenario: Insecure TLS skip is not allowed
|
||||||
|
|
||||||
|
- **WHEN** the SCUM companion client connects to Platform
|
||||||
|
- **THEN** it must not disable certificate verification through an unconditional insecure TLS setting
|
||||||
|
|
||||||
|
### Requirement: SCUM snapshots cover core operational state
|
||||||
|
|
||||||
|
The SCUM plugin SHALL define typed snapshot schemas for at least online sessions, players, squads, vehicles, flags or territories, and companion health diagnostics.
|
||||||
|
|
||||||
|
#### Scenario: Player snapshot is ingested
|
||||||
|
|
||||||
|
- **WHEN** the SCUM companion client uploads a declared player or online-session snapshot
|
||||||
|
- **THEN** Platform stores a versioned safe projection that plugin pages can query by server instance and observed time
|
||||||
|
|
||||||
|
#### Scenario: Vehicle and flag snapshots are ingested
|
||||||
|
|
||||||
|
- **WHEN** the SCUM companion client uploads declared vehicle, squad, flag, or territory snapshots
|
||||||
|
- **THEN** Platform validates the schema version and sequence before updating the current projection
|
||||||
|
|
||||||
|
### Requirement: SCUM command catalog is bounded and permissioned
|
||||||
|
|
||||||
|
The SCUM plugin SHALL expose only declared, typed, permission-scoped game commands such as announcements, player lookup, reward delivery, event actions, maintenance preparation, and safe companion diagnostics.
|
||||||
|
|
||||||
|
#### Scenario: Operator queues announcement
|
||||||
|
|
||||||
|
- **WHEN** an authorized operator queues a declared SCUM announcement command
|
||||||
|
- **THEN** Platform records the requester, approval state, command payload, idempotency key, and audit event before the companion client can claim it
|
||||||
|
|
||||||
|
#### Scenario: Risky command requires approval
|
||||||
|
|
||||||
|
- **WHEN** a SCUM command is marked as risky, destructive, economy-affecting, or punitive
|
||||||
|
- **THEN** Platform requires the declared approval level before making the command claimable
|
||||||
|
|
||||||
|
### Requirement: SCUM moderation begins as evidence and review
|
||||||
|
|
||||||
|
The SCUM plugin SHALL model suspicious duplicate IP, unlock, mine, trade, kill, admin, and economy signals as evidence, alerts, and operator-reviewed recommendations by default.
|
||||||
|
|
||||||
|
#### Scenario: Suspicious event is parsed
|
||||||
|
|
||||||
|
- **WHEN** SCUM semantic log parsing detects a suspicious event pattern
|
||||||
|
- **THEN** Platform records a typed evidence event or alert without automatically banning, punishing, or modifying the player
|
||||||
|
|
||||||
|
#### Scenario: Operator approves punitive action
|
||||||
|
|
||||||
|
- **WHEN** an operator chooses a punitive SCUM action from a recommendation
|
||||||
|
- **THEN** Platform applies the command catalog permission and approval rules before dispatching any companion command
|
||||||
|
|
||||||
|
### Requirement: SCUM logs are parsed into typed events
|
||||||
|
|
||||||
|
The SCUM plugin SHALL define typed semantic log events for chat, login, logout, kill, trade, mine, unlock, admin, and performance records.
|
||||||
|
|
||||||
|
#### Scenario: Chat log event is parsed
|
||||||
|
|
||||||
|
- **WHEN** SCUM chat log lines are ingested
|
||||||
|
- **THEN** Platform stores typed chat events that plugin pages can filter by player, time range, and server instance
|
||||||
|
|
||||||
|
#### Scenario: Performance log event updates metrics
|
||||||
|
|
||||||
|
- **WHEN** SCUM performance log lines are ingested
|
||||||
|
- **THEN** Platform maps FPS and entity counts into bounded metric projections and alert inputs
|
||||||
|
|
||||||
|
### Requirement: SCUM database access uses read-only templates
|
||||||
|
|
||||||
|
The SCUM plugin SHALL declare read-only SCUM.db query templates with typed parameters, bounded result schemas, and permission scopes instead of exposing arbitrary SQL.
|
||||||
|
|
||||||
|
#### Scenario: Operator runs player lookup template
|
||||||
|
|
||||||
|
- **WHEN** an authorized operator runs a declared player lookup query template
|
||||||
|
- **THEN** Platform dispatches a bounded read-only request and returns only the declared result columns
|
||||||
|
|
||||||
|
#### Scenario: Arbitrary SQL is requested
|
||||||
|
|
||||||
|
- **WHEN** a plugin page submits SQL text that is not backed by a declared query template
|
||||||
|
- **THEN** Platform rejects the request and does not dispatch it to Run or the companion client
|
||||||
|
|
||||||
|
### Requirement: SCUM backup and restart policy remains plugin-declared
|
||||||
|
|
||||||
|
The SCUM plugin SHALL declare backup scopes, retention policy, restart schedules, warning announcements, and update-check policy as SCUM operational policy while Platform and Run execute only generic jobs.
|
||||||
|
|
||||||
|
#### Scenario: Scheduled restart is prepared
|
||||||
|
|
||||||
|
- **WHEN** a SCUM restart schedule reaches its warning window
|
||||||
|
- **THEN** Platform queues declared SCUM announcement commands and generic lifecycle actions according to plugin policy and operator approvals
|
||||||
|
|
||||||
|
#### Scenario: Backup is requested
|
||||||
|
|
||||||
|
- **WHEN** an authorized operator requests a SCUM backup
|
||||||
|
- **THEN** Platform records the logical SCUM backup scope and dispatches generic archive/artifact operations without exposing raw host paths or storage credentials to the plugin page
|
||||||
|
|
||||||
|
### Requirement: SCUM operation pages use safe Platform projections
|
||||||
|
|
||||||
|
The SCUM plugin pages SHALL render bridge state, snapshots, commands, logs, backup/restart policy, and review flows using Platform-mediated DTOs only.
|
||||||
|
|
||||||
|
#### Scenario: SCUM operations page loads
|
||||||
|
|
||||||
|
- **WHEN** a user opens the SCUM operations page for a server instance
|
||||||
|
- **THEN** the page can display safe bridge status, companion health, current snapshots, command availability, and recent results without receiving raw secrets, host paths, DSNs, direct Run sockets, or component session material
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
## 1. Platform Bridge Foundation
|
||||||
|
|
||||||
|
- [x] 1.1 Add Game Client Bridge domain types for commands, command states, claims, results, cancellations, snapshots, snapshot streams, retention metadata, and audit references.
|
||||||
|
- [x] 1.2 Add bridge DTOs and validation rules for queue, claim, ack/result, cancel, snapshot ingest, snapshot query, and safe browser projections.
|
||||||
|
- [x] 1.3 Add repository interfaces and in-memory or durable store implementations for bridge commands, snapshots, stream sequence tracking, and retention queries.
|
||||||
|
- [x] 1.4 Add service methods for command creation, idempotency reuse, claim leasing, fencing-token checks, terminal result handling, cancellation, expiry, and audit recording.
|
||||||
|
- [x] 1.5 Add component-session authorization checks so only current Client Manager sessions with `game-client.bridge` can claim commands or upload snapshots.
|
||||||
|
|
||||||
|
## 2. Platform API Surface
|
||||||
|
|
||||||
|
- [x] 2.1 Add operator-facing API routes for listing bridge declarations/status, queueing declared commands, cancelling commands, reading command results, and querying snapshots.
|
||||||
|
- [x] 2.2 Add companion-facing API routes for claiming command batches, acknowledging commands, posting results, uploading snapshots, and reporting bridge diagnostics.
|
||||||
|
- [x] 2.3 Ensure bridge routes expose only safe projections and never expose component keys, sessions, secret refs, host paths, DSNs, direct Run endpoints, sockets, or storage credentials.
|
||||||
|
- [x] 2.4 Add bridge retention and expiry reconciliation paths for old commands, old results, stale claims, and expired snapshots.
|
||||||
|
|
||||||
|
## 3. Plugin Manifest and SDK Contracts
|
||||||
|
|
||||||
|
- [x] 3.1 Extend plugin manifest schema and validation for game-client command catalogs, snapshot schemas, approval levels, permissions, retention policy, and page bridge contracts.
|
||||||
|
- [x] 3.2 Extend plugin SDK/API types so plugin pages can call Platform-mediated bridge actions without receiving raw executor or component secrets.
|
||||||
|
- [x] 3.3 Fix remote-access request input propagation needed by declared read-only database templates before enabling SCUM.db query operations.
|
||||||
|
- [x] 3.4 Add manifest/schema tests for rejected arbitrary SQL, arbitrary shell, raw paths, undeclared commands, unsafe capabilities, and missing approval metadata.
|
||||||
|
|
||||||
|
## 4. SCUM Plugin Operations
|
||||||
|
|
||||||
|
- [x] 4.1 Update the SCUM plugin manifest from local proof placeholders to real `game.scum` operation declarations for lifecycle, client manager, bridge commands, snapshots, logs, and pages.
|
||||||
|
- [x] 4.2 Align SCUM Client Manager packaging/config declarations with the real companion client bootstrap instead of nonexistent `configs/client.template.json`, `config.json`, and unsupported `--config` assumptions.
|
||||||
|
- [x] 4.3 Add typed SCUM snapshot schemas for online sessions, players, squads, vehicles, flags or territories, and companion health diagnostics.
|
||||||
|
- [x] 4.4 Add bounded SCUM command catalog entries for announcements, safe diagnostics, player lookup, reward/event flows, restart preparation, and maintenance actions with permissions and approval levels.
|
||||||
|
- [x] 4.5 Add read-only SCUM.db query template declarations with typed parameters and bounded result schemas.
|
||||||
|
- [x] 4.6 Add SCUM semantic log event declarations for chat, login, logout, kill, trade, mine, unlock, admin, and performance events.
|
||||||
|
|
||||||
|
## 5. Frontend Operations Surface
|
||||||
|
|
||||||
|
- [x] 5.1 Add Platform Web API client/types for bridge declarations, command status, command results, snapshot projections, approval state, and bridge diagnostics.
|
||||||
|
- [x] 5.2 Add SCUM operations page contracts and route wiring through the existing plugin page bridge model.
|
||||||
|
- [x] 5.3 Build the first SCUM operations UI for companion health, command queue/results, players/sessions, vehicles/squads/flags, semantic logs, and maintenance policy using safe Platform projections.
|
||||||
|
- [x] 5.4 Preserve the `platform_web` magical-girl crystal-moonlight operations-console style and avoid page-local fixed decorative effects outside `MagicalParticleLayer`.
|
||||||
|
|
||||||
|
## 6. Companion Client Integration
|
||||||
|
|
||||||
|
- [x] 6.1 Define the generated companion config shape used by Platform Client Manager registration and bridge APIs without exposing browser-visible raw secrets.
|
||||||
|
- [x] 6.2 Adapt the SCUM companion client integration path away from legacy shared-token `/api/v1/scum-clients/*` endpoints toward Platform component-session registration, heartbeat, command claim/result, and snapshot upload.
|
||||||
|
- [x] 6.3 Remove unconditional insecure TLS behavior from the companion integration path and add tests or checks for secure transport defaults.
|
||||||
|
- [x] 6.4 Add local smoke fixtures or documentation showing how a compatible `scum_client` package claims a command and uploads a typed snapshot.
|
||||||
|
|
||||||
|
## 7. Tests and Verification
|
||||||
|
|
||||||
|
- [x] 7.1 Add backend unit tests for bridge idempotency, claim leasing, fencing, cancellation, expiry, result recording, component-session authorization, safe projections, and snapshot sequence handling.
|
||||||
|
- [x] 7.2 Add plugin manifest validation tests for the SCUM operation declarations and unsafe legacy-pattern rejection.
|
||||||
|
- [x] 7.3 Add frontend tests for SCUM bridge API typing, disabled availability reasons, command approval states, and safe rendering without secrets.
|
||||||
|
- [x] 7.4 Run focused Go and frontend tests covering changed packages.
|
||||||
|
- [x] 7.5 Run `openspec validate implement-game-client-bridge-and-scum-operations --strict`.
|
||||||
|
- [x] 7.6 Run `scripts/check-structure.sh`.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-18
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Item 9 made the console truthful about API-backed operational state, current-session operations, and safe projections. The remaining production gap is not another dashboard facade; it is durable governance: admission control under capacity pressure, alert lifecycle closure, complete plugin lifecycle state, and real provider-backed AI assistance. The implementation must reuse Platform as the authority and Run as a channelized executor while keeping plugin pages and browser code away from raw secrets, direct sockets, host paths, and provider transport details.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Govern server and Run capacity through Platform admission checks, safe endpoint capacity reports, queued/retrying jobs, and explicit capacity-denied responses.
|
||||||
|
- Persist alerts with source, severity, state, acknowledgement, scoped retry, resolution, suppression windows, audit links, and safe diagnostic text.
|
||||||
|
- Model complete plugin lifecycle state and compatibility gates in Platform, and drive lifecycle jobs through the existing job channel.
|
||||||
|
- Add real AI Provider invocation through a provider client that uses Platform-stored key/base URL material and never returns raw credentials or transport details.
|
||||||
|
- Require AI-generated config changes to produce a diff/recommendation that must be approved before any run-side write job is dispatched.
|
||||||
|
- Surface governance, alerts, lifecycle, and AI states in existing first-party pages without replacing the visual system or full-width management surfaces.
|
||||||
|
- Extend Run capacity/lifecycle contracts with bounded metadata only, preserving channel isolation and priority.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- No billing, cloud host sales, cloud/agent provider marketplace, or SaaS marketplace expansion.
|
||||||
|
- No arbitrary shell, direct Run socket/endpoint, host path projection, raw PID/socket/DSN/RCON, or raw credential/token/key/base URL exposure.
|
||||||
|
- No browser/plugin storage of provider secrets.
|
||||||
|
- No replacement of the five required first-party areas or the black-mecha/magical-girl theme system.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: Platform owns production admission decisions
|
||||||
|
|
||||||
|
Run heartbeats report bounded capacity metadata. Platform combines that with server bindings, queued/running job pressure, endpoint capability availability, and configured limits before accepting lifecycle, dependency, backup, update, and plugin jobs. Rejected work returns a safe capacity or compatibility reason and records audit/alert evidence.
|
||||||
|
|
||||||
|
Alternative considered: let Run reject after claim. Rejected because operators need immediate Platform feedback, and Run-only rejection creates noisy retry loops.
|
||||||
|
|
||||||
|
### Decision 2: Alerts are durable state, not transient UI messages
|
||||||
|
|
||||||
|
Capacity pressure, failed lifecycle work, stale endpoints, AI provider failures, plugin compatibility failures, and unsafe request denials create or update durable alert records. Alerts have explicit acknowledgement and resolution transitions with actor and audit metadata. Current-session UI operations remain separate and cannot close durable alerts by themselves.
|
||||||
|
|
||||||
|
Alternative considered: derive alerts only from latest jobs/metrics. Rejected because acknowledgement, suppression, and resolution are production workflows that must survive restart.
|
||||||
|
|
||||||
|
### Decision 3: Plugin lifecycle state is managed by Platform
|
||||||
|
|
||||||
|
Marketplace enablement remains a catalog state, while lifecycle operations track installed plugin version, target server compatibility, dependency status, desired state, current state, upgrade/rollback availability, linked job IDs, and audit/alert summaries. Plugin manifests and bridge calls declare capabilities and purposes, but Platform authorizes and dispatches all jobs.
|
||||||
|
|
||||||
|
Alternative considered: plugin pages manage lifecycle locally through bridge state. Rejected because that would bypass durable authorization and job/audit truth.
|
||||||
|
|
||||||
|
### Decision 4: Real AI Provider calls use a redacted provider client
|
||||||
|
|
||||||
|
Platform selects an active provider, decrypts key/base URL material inside the service boundary, sends bounded requests through a provider client, and returns only safe recommendation/diff/usage metadata. Tests and local mode can still use deterministic clients, but production code must support a real HTTP provider path with redacted failures.
|
||||||
|
|
||||||
|
Alternative considered: expose base URLs or keys to plugin pages for direct calls. Rejected by the repository AI Provider rules and secret boundary.
|
||||||
|
|
||||||
|
### Decision 5: AI config changes are two-step
|
||||||
|
|
||||||
|
AI can suggest a structured diff for configuration or lifecycle inputs. Platform stores the diff preview and requires explicit approval before dispatching a run-side config write job. Approval is scoped to actor/server/plugin/config revision and stale approvals are denied.
|
||||||
|
|
||||||
|
Alternative considered: dispatch config writes directly after AI completion. Rejected because AI suggestions must be reviewable before mutation.
|
||||||
|
|
||||||
|
### Decision 6: Run receives bounded lifecycle and capacity metadata only
|
||||||
|
|
||||||
|
Run protocol additions are limited to safe capacity dimensions, pressure reasons, lifecycle capability names, and logical job inputs. They do not carry raw host paths, process IDs, sockets, credentials, provider configuration, or browser/plugin transport material.
|
||||||
|
|
||||||
|
Alternative considered: add a general operations channel. Rejected because existing control/job/log/artifact/game-client channels already define the isolation model.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] Alert deduplication can hide repeated incidents. Mitigation: keep occurrence counts, last-seen timestamps, state transitions, and audit references.
|
||||||
|
- [Risk] Capacity admission can reject recoverable work too early. Mitigation: use explicit retry-after hints, scoped retry, and distinguish hard compatibility failures from temporary pressure.
|
||||||
|
- [Risk] Real provider calls can leak transport errors. Mitigation: redact provider failures and validate every response before returning it.
|
||||||
|
- [Risk] Lifecycle state overlaps existing marketplace state. Mitigation: keep marketplace catalog state separate from server/plugin lifecycle installations.
|
||||||
|
- [Risk] Broad verification may be expensive. Mitigation: add focused unit tests first, then run full platform_web, plugins, platform, run, structure, OpenSpec, diff, and browser checks before completion.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Add OpenSpec artifacts and validate strictly before implementation.
|
||||||
|
2. Add shared Platform domain/DTO/validator/model/repo/service support for capacity governance, alerts, plugin lifecycle, and AI diff approvals.
|
||||||
|
3. Add Platform API routes and route docs with named DTOs and OpenAPI-style handler comments.
|
||||||
|
4. Extend plugin manifest/SDK/bridge contracts and examples with lifecycle and mediated AI purpose metadata.
|
||||||
|
5. Extend Run protocol/runtime capacity and lifecycle metadata without crossing channel boundaries.
|
||||||
|
6. Update platform_web contracts/pages/theme/tests to surface capacity, alerts, lifecycle, and AI diff approval using existing first-party surfaces.
|
||||||
|
7. Run full verification and record evidence in this change's tasks.
|
||||||
|
|
||||||
|
Rollback is additive: remove the new governance/lifecycle/alert/AI diff approval routes and UI panels, while preserving existing item 9 console behavior and earlier durable job/channel mechanics.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The platform now has a real API-backed operations console and durable Run channels, but production operators still need stronger capacity governance, alert closure, full plugin lifecycle state, and a non-mock Platform-mediated AI Provider path. These areas were intentionally left as future work in prior changes and now need to become first-class, durable workflows without weakening the existing browser, plugin, Run, and secret boundaries.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add production capacity governance for Run endpoints and server instances: safe capacity reports, admission decisions, pressure alerts, and bounded retry/reconciliation.
|
||||||
|
- Add durable alert rules/events with acknowledgement, scoped retry, resolution, audit evidence, and UI surfacing across overview, server detail, maintenance, and operation history.
|
||||||
|
- Complete plugin lifecycle beyond marketplace state: install, enable, disable, upgrade, rollback, retire, dependency checks, compatibility gates, lifecycle jobs, and durable audit/alert hooks.
|
||||||
|
- Replace mock-only AI invocation with a real provider-client integration path that keeps keys and base URLs in Platform, validates plugin purposes, returns redacted recommendations, and requires reviewable diff approval before config writes.
|
||||||
|
- Extend Run channel contracts only with bounded capacity and lifecycle metadata; do not add direct browser-to-Run access or plugin-owned transports.
|
||||||
|
- Preserve the required first-party areas and the black-mecha / magical-girl crystal-moonlight operations console style.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `production-operations-governance`: Platform-owned capacity governance, alert closure, complete plugin lifecycle, and real mediated AI Provider operations.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects `platform/` domain, DTOs, validators, repositories, services, API routes, tests, and docs.
|
||||||
|
- Affects `platform_web/` API contracts, page contracts, first-party pages, shared operation components, theme styles, browser acceptance, and tests.
|
||||||
|
- Affects `plugins/` manifest schema, SDK/bridge contracts, examples, validation tests, and docs.
|
||||||
|
- Affects the independent `run/` protocol/runtime with bounded capacity/lifecycle metadata and tests.
|
||||||
|
- Does not add billing, cloud host sales, cloud/agent provider marketplace, arbitrary shell, direct Run socket/endpoint exposure, or raw credential projection.
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Platform governs production capacity
|
||||||
|
|
||||||
|
Platform SHALL make admission and scheduling decisions using persisted server bindings, endpoint capability support, current Run capacity reports, durable job pressure, and configured safety limits before dispatching production lifecycle work.
|
||||||
|
|
||||||
|
#### Scenario: Capacity accepts bounded work
|
||||||
|
- **WHEN** an authorized operator requests a plugin lifecycle, dependency, backup, update, or server lifecycle operation and an assigned endpoint is online with matching capabilities and available capacity
|
||||||
|
- **THEN** Platform MUST create one durable job with safe capacity metadata, audit the admission decision, and expose a safe job projection
|
||||||
|
|
||||||
|
#### Scenario: Capacity denies pressure safely
|
||||||
|
- **WHEN** the assigned endpoint is offline, stale, missing capability support, over its configured running/queued limits, or under log/artifact/backlog pressure
|
||||||
|
- **THEN** Platform MUST reject or defer the request with a safe retry-after/capacity reason, MUST NOT fabricate success, and MUST NOT expose raw endpoint addresses, sockets, paths, credentials, leases, or session tokens
|
||||||
|
|
||||||
|
### Requirement: Run reports bounded capacity without blocking priority channels
|
||||||
|
|
||||||
|
Run SHALL report only bounded capacity dimensions on control heartbeat and job claim metadata, and SHALL keep control, job lifecycle, log ingest, artifact transfer, and optional game-client bridge channels independent.
|
||||||
|
|
||||||
|
#### Scenario: Artifact backlog exists during heartbeat
|
||||||
|
- **WHEN** artifact or component transfer backlog is present
|
||||||
|
- **THEN** Run MUST continue sending control heartbeat and job ack/result/cancel/reconcile metadata without embedding artifact chunks, host paths, local file names, credentials, sockets, or provider configuration
|
||||||
|
|
||||||
|
#### Scenario: Capacity report is sanitized
|
||||||
|
- **WHEN** Platform or browser views Run endpoint capacity
|
||||||
|
- **THEN** visible capacity MUST include only logical counts, percentages, pressure codes, and timestamps, never raw host paths, PIDs, sockets, DSNs, RCON values, secrets, leases, or direct endpoint URLs
|
||||||
|
|
||||||
|
### Requirement: Alerts are durable and closeable
|
||||||
|
|
||||||
|
Platform SHALL persist alert records for production incidents including capacity pressure, endpoint staleness, failed jobs, lifecycle failures, plugin compatibility failures, unsafe request denials, and AI provider failures.
|
||||||
|
|
||||||
|
#### Scenario: Alert is acknowledged and resolved
|
||||||
|
- **WHEN** an authorized operator acknowledges and later resolves an active alert
|
||||||
|
- **THEN** Platform MUST update durable alert state, actor, timestamps, audit references, and safe resolution notes without mutating unrelated alerts
|
||||||
|
|
||||||
|
#### Scenario: Alert retry remains scoped
|
||||||
|
- **WHEN** an operator retries a failed alert source such as endpoint capacity refresh, plugin lifecycle check, or AI provider test
|
||||||
|
- **THEN** only that source is retried, busy state prevents duplicate submission, and success/failure is reflected from Platform responses rather than local timers
|
||||||
|
|
||||||
|
### Requirement: Plugin lifecycle is complete and platform-mediated
|
||||||
|
|
||||||
|
Platform SHALL manage plugin lifecycle installations separately from marketplace catalog state, including install, enable, disable, upgrade, rollback, retire, dependency checks, compatibility gates, desired/current state, job linkage, audit history, and alert integration.
|
||||||
|
|
||||||
|
#### Scenario: Plugin upgrade is approved
|
||||||
|
- **WHEN** an authorized operator approves an upgrade for a server-bound plugin installation with a compatible target version, satisfied dependency checks, and endpoint capability support
|
||||||
|
- **THEN** Platform MUST create a durable lifecycle job and transition state only from job results or reconciliation evidence
|
||||||
|
|
||||||
|
#### Scenario: Plugin rollback is repeated
|
||||||
|
- **WHEN** rollback is retried with the same idempotency key and immutable inputs
|
||||||
|
- **THEN** Platform MUST return the original lifecycle/job result and MUST reject the same key with different immutable inputs
|
||||||
|
|
||||||
|
#### Scenario: Unsafe lifecycle input is rejected
|
||||||
|
- **WHEN** plugin lifecycle metadata, manifest actions, bridge requests, or job inputs contain arbitrary shell, raw credentials, direct Run endpoints, host paths, PIDs, sockets, DSNs, RCON data, unknown capabilities, or undeclared AI purposes
|
||||||
|
- **THEN** validation MUST reject the request before registration or dispatch
|
||||||
|
|
||||||
|
### Requirement: Plugins request AI only through Platform-mediated capability
|
||||||
|
|
||||||
|
Plugin manifests, SDK calls, and platform-hosted plugin pages SHALL request AI assistance only through typed Platform bridge/API contracts that declare purpose, request ID, scoped input, and context references.
|
||||||
|
|
||||||
|
#### Scenario: Declared AI purpose is invoked
|
||||||
|
- **WHEN** a plugin invokes a declared AI purpose through the Platform bridge
|
||||||
|
- **THEN** Platform MUST validate plugin installation state, route permission, server scope, purpose, payload bounds, provider availability, and unsafe text before invoking a provider client
|
||||||
|
|
||||||
|
#### Scenario: Undeclared AI purpose is denied
|
||||||
|
- **WHEN** a plugin invokes an undeclared or currently unauthorized AI purpose
|
||||||
|
- **THEN** Platform MUST deny the request with a safe error and MUST NOT call a provider client
|
||||||
|
|
||||||
|
### Requirement: Real AI Provider integration stays inside Platform
|
||||||
|
|
||||||
|
Platform SHALL support a real provider client path that reads provider keys and base URL material only inside Platform service boundaries, invokes enabled providers with bounded requests, and returns redacted responses.
|
||||||
|
|
||||||
|
#### Scenario: Real provider returns recommendation
|
||||||
|
- **WHEN** an enabled provider is selected and the provider client succeeds
|
||||||
|
- **THEN** Platform MUST return safe recommendation, model, usage, request ID, and optional diff preview metadata without raw API keys, bearer tokens, base URL secrets, transport headers, storage material, host paths, sockets, or credentials
|
||||||
|
|
||||||
|
#### Scenario: Provider fails
|
||||||
|
- **WHEN** provider transport, authentication, rate limit, or model invocation fails
|
||||||
|
- **THEN** Platform MUST persist a safe audit/alert, return a redacted failure, and MUST NOT expose raw provider URL, headers, keys, request body secrets, or stack traces
|
||||||
|
|
||||||
|
### Requirement: AI config changes require reviewable diff approval
|
||||||
|
|
||||||
|
AI-generated configuration changes SHALL be persisted as reviewable recommendations or diff previews and SHALL require separate operator approval before Platform dispatches any run-side config write job.
|
||||||
|
|
||||||
|
#### Scenario: Diff is approved
|
||||||
|
- **WHEN** an authorized operator approves a current, matching AI config diff preview for the same server, plugin, actor scope, and config revision
|
||||||
|
- **THEN** Platform MUST dispatch one bounded config write job and link the job, approval, audit event, and source AI request
|
||||||
|
|
||||||
|
#### Scenario: Diff is stale or cancelled
|
||||||
|
- **WHEN** the config revision changes, the approval is cancelled, or the preview has expired
|
||||||
|
- **THEN** Platform MUST reject dispatch, preserve the recommendation for review history, and avoid run-side mutation
|
||||||
|
|
||||||
|
### Requirement: Console surfaces governance without changing visual direction
|
||||||
|
|
||||||
|
platform_web SHALL surface capacity governance, alert closure, plugin lifecycle, and AI diff approval in the existing first-party operations console using real Platform APIs, permissions, confirmation, busy state, scoped retry, and failure recovery.
|
||||||
|
|
||||||
|
#### Scenario: Operator closes alert in the console
|
||||||
|
- **WHEN** an authorized operator acknowledges or resolves an alert
|
||||||
|
- **THEN** the UI MUST dispatch one Platform request, block duplicate submission while pending, refresh from persisted response, and show failure recovery without claiming local success
|
||||||
|
|
||||||
|
#### Scenario: Narrow viewport renders governance surfaces
|
||||||
|
- **WHEN** the console is rendered at 390px in black-mecha or magical-girl theme
|
||||||
|
- **THEN** capacity, alerts, lifecycle, AI diff review, confirmations, and operation rows MUST remain readable, bounded, and theme-consistent without page-local global decoration
|
||||||
|
|
||||||
|
### Requirement: Verification covers production governance boundaries
|
||||||
|
|
||||||
|
The change SHALL include platform tests, run tests, plugin typecheck/tests/manifest validation, platform_web typecheck/tests/build, browser acceptance evidence, structure validation, strict OpenSpec validation, two-repository diff whitespace checks, and forbidden-field scans before tasks are marked complete.
|
||||||
|
|
||||||
|
#### Scenario: Verification evidence is recorded
|
||||||
|
- **WHEN** implementation tasks are completed
|
||||||
|
- **THEN** `tasks.md` MUST record real command/browser evidence and MUST NOT mark verification complete until those checks pass
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## 1. OpenSpec and Architecture
|
||||||
|
|
||||||
|
- [x] 1.1 Create proposal, design, spec, and task artifacts for production operations governance.
|
||||||
|
- [x] 1.2 Run `openspec validate implement-production-operations-governance --strict` before implementation.
|
||||||
|
|
||||||
|
## 2. Platform Capacity, Alerts, and Lifecycle
|
||||||
|
|
||||||
|
- [x] 2.1 Add domain/DTO/validator/model/repo/service support for production capacity governance and safe admission decisions.
|
||||||
|
- [x] 2.2 Add durable alert records with acknowledge, resolve, scoped retry, audit linkage, deduplication, and safe diagnostic text.
|
||||||
|
- [x] 2.3 Add complete plugin lifecycle installation state, compatibility gates, idempotency fences, upgrade/rollback/retire operations, and job/audit/alert integration.
|
||||||
|
- [x] 2.4 Add real AI Provider client integration plus redacted provider failure handling and alert/audit evidence.
|
||||||
|
- [x] 2.5 Add AI config diff preview/approval persistence and dispatch gating before run-side config write jobs.
|
||||||
|
- [x] 2.6 Update Platform API routes, route docs, protocol docs, and tests.
|
||||||
|
|
||||||
|
## 3. Plugin SDK and Bridge
|
||||||
|
|
||||||
|
- [x] 3.1 Extend manifest schema and examples with production lifecycle and AI purpose declarations.
|
||||||
|
- [x] 3.2 Extend SDK/bridge contracts to request lifecycle/AI through Platform-mediated capabilities only.
|
||||||
|
- [x] 3.3 Add validation/typecheck/tests proving unsafe lifecycle input and raw credential/direct Run fields are rejected.
|
||||||
|
|
||||||
|
## 4. Run Protocol and Runtime
|
||||||
|
|
||||||
|
- [x] 4.1 Extend Run protocol capacity reports and job metadata with bounded pressure/lifecycle fields only.
|
||||||
|
- [x] 4.2 Update Run runtime capacity reporting and lifecycle handling without blocking control/job/log/artifact channels.
|
||||||
|
- [x] 4.3 Add Run tests for capacity sanitization, channel isolation under backlog, and lifecycle metadata validation.
|
||||||
|
|
||||||
|
## 5. platform_web Console
|
||||||
|
|
||||||
|
- [x] 5.1 Add typed API/contracts for capacity governance, alerts, plugin lifecycle, AI diff preview, and approval.
|
||||||
|
- [x] 5.2 Surface alerts and capacity on overview, server detail, maintenance, and operation history with confirmation, busy state, scoped retry, persisted responses, and failure recovery.
|
||||||
|
- [x] 5.3 Surface full plugin lifecycle and AI diff approval in existing first-party pages using shared themed surfaces.
|
||||||
|
- [x] 5.4 Add tests for permissions, duplicate prevention, failed recovery, forbidden-field omission, and 390px theme-safe rendering.
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
- [x] 6.1 Run platform Go tests.
|
||||||
|
- [x] 6.2 Run independent Run Go tests.
|
||||||
|
- [x] 6.3 Run plugins typecheck, tests, and manifest validation.
|
||||||
|
- [x] 6.4 Run platform_web typecheck, tests, and production build.
|
||||||
|
- [x] 6.5 Run browser acceptance for the five first-party areas, server detail, alerts, plugin lifecycle, AI diff approval, desktop and 390px, black-mecha and magical-girl.
|
||||||
|
- [x] 6.6 Scan rendered outputs for raw key/token/secret/base URL secret/path/PID/socket/credential/DSN/RCON/direct Run endpoint fragments.
|
||||||
|
- [x] 6.7 Run `scripts/check-structure.sh`, `openspec validate implement-production-operations-governance --strict`, and both repository `git diff --check`.
|
||||||
|
- [x] 6.8 Record real verification evidence in this file before marking verification tasks complete.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- 2026-07-18 19:02 CST: `cd platform && go test ./...` passed for all Platform packages; `cd /Users/tasia/Desktop/code/run && go test ./...` passed for the independent Run repository.
|
||||||
|
- `cd plugins && npm run typecheck`, `npm test -- --run`, and `npm run validate:manifest` passed: 1 test file / 19 tests and all three example manifests validated. The manifest command was rerun outside the filesystem sandbox because `tsx` requires a local IPC pipe.
|
||||||
|
- `cd platform_web && npm run typecheck`, `npm test -- --run`, and `npm run build` passed: 26 test files / 135 tests, 1,829 production modules transformed, and the production bundle completed successfully.
|
||||||
|
- `scripts/local-debug-smoke.sh` and `npm --prefix platform_web run acceptance:browser` passed against the real local Platform and independent Run stack rooted at `/private/tmp/browser-local-debug-acceptance-10o`. `node --check platform_web/acceptance/browser-acceptance.mjs` and `bash -n scripts/local-debug-env.sh scripts/local-debug-smoke.sh scripts/local-debug-start.sh` also passed.
|
||||||
|
- Capacity evidence recorded a persisted `denied` admission with bounded `queue.limit` and `capability.missing` pressure codes plus a linked durable alert and audit event. Alert cancellation preserved `active`; confirmed acknowledgement persisted `acknowledged`, actor, timestamp, and audit linkage.
|
||||||
|
- Plugin lifecycle install and enable each produced durable Platform jobs. The persisted installation reached the requested enabled state with linked job and audit IDs; browser scans passed after the lifecycle interactions.
|
||||||
|
- AI diff cancellation preserved `pending`; explicit approval persisted `approved` and dispatched exactly one `config.write` job. Independent Run completed that job as `succeeded` with a safe `file.write` result at version 2 and an `atomic compare-and-swap file write` audit summary; Platform configuration advanced from version 1 to 2 before the subsequent real `process.stop` request.
|
||||||
|
- Browser acceptance covered 首页、服务器管理、插件市场、用户管理、AI 提供商管理, server detail, maintenance/operation history, alert closure, plugin lifecycle, and AI diff approval in four scenarios: 1440x960 and 390x844 for `mecha-black` and `magical-girl`. All 38 route/interaction checks reported zero horizontal overflow, zero overlapping controls, and no tiny visible text boxes.
|
||||||
|
- API and rendered-output scans passed for raw key/token/secret/base URL secret, host path, PID, socket, credential, DSN, RCON, session/direct Run endpoint, and plugin-owned transport fragments. Confirm/cancel, busy duplicate prevention, persisted terminal state, real failure recovery, and scoped retry evidence are included in the structured artifact.
|
||||||
|
- `scripts/check-structure.sh`, `openspec validate implement-production-operations-governance --strict`, the main repository `git diff --check`, and the independent Run repository `git diff --check` passed. These gates were rerun after recording this evidence.
|
||||||
|
- Structured browser evidence: `/private/tmp/browser-local-debug-acceptance-10o/browser-acceptance/browser-acceptance-evidence.json` and `/private/tmp/browser-local-debug-acceptance-10o/browser-acceptance/item-10-evidence.json`. Durable Platform/Run result snapshot: `/private/tmp/browser-local-debug-acceptance-10o/platform/metadata.json`.
|
||||||
@@ -30,29 +30,35 @@
|
|||||||
|
|
||||||
## 5. Run Deployment and Supervision
|
## 5. Run Deployment and Supervision
|
||||||
|
|
||||||
- [ ] 5.1 Add Run protocol payloads and validation for client-manager deploy/control/update/rollback/uninstall, immutable fences, lifecycle progress/results, and endpoint capabilities.
|
- [x] 5.1 Add Run protocol payloads and validation for client-manager deploy/control/update/rollback/uninstall, immutable fences, lifecycle progress/results, and endpoint capabilities.
|
||||||
- [ ] 5.2 Add a scoped client-manager workspace and durable local journal for installation slots, chunk offsets/checksums, deployment/attempt/key generations, idempotency outcomes, process state, and restart reconciliation.
|
- [x] 5.2 Add a scoped client-manager workspace and durable local journal for installation slots, chunk offsets/checksums, deployment/attempt/key generations, idempotency outcomes, process state, and restart reconciliation.
|
||||||
- [ ] 5.3 Implement resumable artifact download, checksum verification, safe archive extraction, staging, atomic active/previous activation, cancellation checkpoints, and rejection of traversal/symlinks/device files/unexpected layouts.
|
- [x] 5.3 Implement resumable artifact download, checksum verification, safe archive extraction, staging, atomic active/previous activation, cancellation checkpoints, and rejection of traversal/symlinks/device files/unexpected layouts.
|
||||||
- [ ] 5.4 Implement bounded declarative client-manager start/stop/restart/status supervision with fixed relative executable, safe timeouts, logical health, and no path/PID/socket projection.
|
- [x] 5.4 Implement bounded declarative client-manager start/stop/restart/status supervision with fixed relative executable, safe timeouts, logical health, and no path/PID/socket projection.
|
||||||
- [ ] 5.5 Implement staged update health confirmation, automatic rollback, explicit rollback, stale/revoked generation rejection, re-entry after restart, and real phase reporting.
|
- [x] 5.5 Implement staged update health confirmation, automatic rollback, explicit rollback, stale/revoked generation rejection, re-entry after restart, and real phase reporting.
|
||||||
- [ ] 5.6 Implement idempotent safe uninstall that stops the process and deletes only controlled installation slots/journal/session material without following links or touching server/shared files.
|
- [x] 5.6 Implement idempotent safe uninstall that stops the process and deletes only controlled installation slots/journal/session material without following links or touching server/shared files.
|
||||||
- [ ] 5.7 Add Run tests for deploy resume/checksum, fences, cancel/retry/stale attempts, reconciliation, supervision, health failure rollback, uninstall safety, redaction, and channel isolation under stalled client-manager traffic.
|
- [x] 5.7 Add Run tests for deploy resume/checksum, fences, cancel/retry/stale attempts, reconciliation, supervision, health failure rollback, uninstall safety, redaction, and channel isolation under stalled client-manager traffic.
|
||||||
|
|
||||||
## 6. platform_web Lifecycle Workspace
|
## 6. platform_web Lifecycle Workspace
|
||||||
|
|
||||||
- [ ] 6.1 Add API types, schemas, client methods, safe action projections, polling/job progress integration, and tests for Client Manager lifecycle summaries and commands.
|
- [x] 6.1 Add API types, schemas, client methods, safe action projections, polling/job progress integration, and tests for Client Manager lifecycle summaries and commands.
|
||||||
- [ ] 6.2 Build a rich Server Detail Client Manager workspace showing build/artifact, desired/active/previous version, deployment/registration/online health, last seen, real current job phases, retry guidance, and action availability.
|
- [x] 6.2 Build a rich Server Detail Client Manager workspace showing build/artifact, desired/active/previous version, deployment/registration/online health, last seen, real current job phases, retry guidance, and action availability.
|
||||||
- [ ] 6.3 Add start/stop/restart, deploy/redeploy, update/rollback, session revoke, key reset recovery, retry, and uninstall confirmation/error flows while preserving compact server action menus and both existing themes.
|
- [x] 6.3 Add start/stop/restart, deploy/redeploy, update/rollback, session revoke, key reset recovery, retry, and uninstall confirmation/error flows while preserving compact server action menus and both existing themes.
|
||||||
- [ ] 6.4 Add frontend tests and browser acceptance for full state/action coverage, real progress/failure recovery, destructive confirmations, 401/403 behavior, responsive layouts, theme preservation, and secret/path/PID/socket redaction.
|
- [x] 6.4 Add frontend tests and browser acceptance for full state/action coverage, real progress/failure recovery, destructive confirmations, 401/403 behavior, responsive layouts, theme preservation, and secret/path/PID/socket redaction.
|
||||||
|
|
||||||
## 7. Documentation and Verification
|
## 7. Documentation and Verification
|
||||||
|
|
||||||
- [ ] 7.1 Update Platform, Run, plugins, SDK, platform_web, route, domain, protocol, and deployment docs with lifecycle states, security/session boundaries, operations, recovery, and explicit production non-goals.
|
- [x] 7.1 Update Platform, Run, plugins, SDK, platform_web, route, domain, protocol, and deployment docs with lifecycle states, security/session boundaries, operations, recovery, and explicit production non-goals.
|
||||||
- [ ] 7.2 Run plugin manifest validation, plugin SDK/tests/typecheck, Platform `go test -count=1 ./...`, independent Run `go test -count=1 ./...`, and focused race/restart checks where practical.
|
- [x] 7.2 Run plugin manifest validation, plugin SDK/tests/typecheck, Platform `go test -count=1 ./...`, independent Run `go test -count=1 ./...`, and focused race/restart checks where practical.
|
||||||
- [ ] 7.3 Run platform_web tests, typecheck, production build, and a browser walkthrough for the touched Server Detail and server action workflows in both visual themes.
|
- [x] 7.3 Run platform_web tests, typecheck, production build, and a browser walkthrough for the touched Server Detail and server action workflows in both visual themes.
|
||||||
- [ ] 7.4 Run shell/compose checks, `scripts/check-structure.sh`, and `git diff --check` in both the main repository and independent Run checkout.
|
- [x] 7.4 Run shell/compose checks, `scripts/check-structure.sh`, and `git diff --check` in both the main repository and independent Run checkout.
|
||||||
- [ ] 7.5 Run `openspec validate implement-secure-client-manager-lifecycle --strict` and record all verification evidence below before marking implementation complete.
|
- [x] 7.5 Run `openspec validate implement-secure-client-manager-lifecycle --strict` and record all verification evidence below before marking implementation complete.
|
||||||
|
|
||||||
## Verification Evidence
|
## Verification Evidence
|
||||||
|
|
||||||
Pending implementation and verification.
|
- `plugins`: `npm run typecheck` passed; `npm test` passed (1 file, 18 tests); `npm run validate:manifest` passed for dev, SCUM, and Minecraft manifests (tsx IPC required the approved escalated run).
|
||||||
|
- `platform`: `go test -count=1 ./...` passed for api/config/domain/dto/model/repo/service/validator packages.
|
||||||
|
- independent `run`: `go test -count=1 ./...` passed for api/config/protocol/runtime/spool; Client Manager tests cover chunk resume/checksum, restart journal resume, traversal archive rejection, idempotency, stale fences, automatic update restore, safe uninstall, redaction-safe results, and controlled supervisor paths.
|
||||||
|
- `platform_web`: `npm run typecheck`, `npm test` (20 files, 111 tests), and `npm run build` passed. Added lifecycle API/schema tests, 401/403 behavior remains covered by existing client tests, and source assertions cover secret/path/PID/socket redaction and confirmation flows.
|
||||||
|
- Browser walkthrough: local Platform-backed console at `http://127.0.0.1:5174/` logged in as the seeded operator, opened `SCUM Alpha` Server Detail, verified the `Client Manager 生命周期` panel and real empty-state gating, switched magical-girl and black-mecha palettes, and checked the panel at 390x844 (`362px` wide, no horizontal overflow).
|
||||||
|
- Shell/compose: `for file in scripts/*.sh; do bash -n "$file"; done` passed; `docker compose config` passed; `scripts/check-structure.sh` passed; `git diff --check` passed in the main repository and independent run checkout.
|
||||||
|
- `openspec validate implement-secure-client-manager-lifecycle --strict` passed.
|
||||||
|
|||||||
@@ -79,4 +79,10 @@ For Docker, the root `docker-compose.yml` sets platform data under `/data/platfo
|
|||||||
|
|
||||||
Current executable behavior includes the platform API, durable hashed auth/Run sessions with expiry/revocation/rotation, strict production route authorization, durable file-backed metadata, segmented log bodies, authenticated run control/job/log/artifact routes, plugin bridge dispatch, platform-mediated AI invocation, real typed dependency execution orchestration with reviewed plan digests, and target-fenced transactional Run self-update staging/health/rollback projections.
|
Current executable behavior includes the platform API, durable hashed auth/Run sessions with expiry/revocation/rotation, strict production route authorization, durable file-backed metadata, segmented log bodies, authenticated run control/job/log/artifact routes, plugin bridge dispatch, platform-mediated AI invocation, real typed dependency execution orchestration with reviewed plan digests, and target-fenced transactional Run self-update staging/health/rollback projections.
|
||||||
|
|
||||||
|
### Client Manager lifecycle
|
||||||
|
|
||||||
|
Client Manager installations are durable aggregates separate from Run distributions and sessions. Their safe state projection is `requested -> building -> available -> deploying -> installed -> registering -> online`, with `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `failed`, and `uninstalled` recovery states. Deploy, control, update, rollback, revoke-session, retry, and uninstall are typed jobs; Platform persists intent before dispatch and gates each action by actor/server ownership, plugin profile, binding, endpoint capability, artifact target/revision, key generation, and lifecycle state.
|
||||||
|
|
||||||
|
Component registration uses the current client-manager key generation, a timestamped nonce, and a short-lived hashed component session. It never reuses a Run session or job lease. Key reset revokes old sessions/artifacts and marks the installation for current-generation rebuild/redeploy. Run reports only logical health, phase, and bounded execution evidence; host paths, PIDs, sockets, raw keys, and credential material are not operator or plugin projections. Production KMS/code-signing, private source credentials, and fleet rollout remain explicit non-goals.
|
||||||
|
|
||||||
Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata. Server creation selects a declared profile, saves complete logical bindings before install dispatch, and existing lifecycle/runtime actions are gated when the binding is absent or incomplete. Browser and plugin-facing responses expose readiness only, not binding values. This change uses controlled secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks.
|
Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata. Server creation selects a declared profile, saves complete logical bindings before install dispatch, and existing lifecycle/runtime actions are gated when the binding is absent or incomplete. Browser and plugin-facing responses expose readiness only, not binding values. This change uses controlled secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks.
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func publicAPIRequest(r *http.Request) bool {
|
|||||||
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" || path == "/api/v1/client-managers/register" || path == "/api/v1/client-managers/heartbeat" {
|
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" || path == "/api/v1/client-managers/register" || path == "/api/v1/client-managers/heartbeat" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(path, "/api/v1/game-client-bridge/companion/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"browser.local/platform/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *coreHandlers) gameClientBridgeCompanionClaim(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.GameClientBridgeClaimRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commands, err := h.core.ClaimGameClientBridgeCommands(request.ToDomain())
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.GameClientBridgeClaimResponseFromDomain(commands))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) gameClientBridgeCompanionAck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.GameClientBridgeAckRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command, err := h.core.AckGameClientBridgeCommand(request.ToDomain(r.PathValue("commandId")))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.GameClientBridgeAckFromDomain(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) gameClientBridgeCompanionResult(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.GameClientBridgeResultRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command, err := h.core.CompleteGameClientBridgeCommand(request.ToDomain(r.PathValue("commandId")))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.GameClientBridgeResultFromDomain(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) gameClientBridgeCompanionSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.GameClientBridgeSnapshotIngestRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshot, err := h.core.UploadGameClientBridgeSnapshot(request.ToDomain())
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeSnapshotIngestFromDomain(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) gameClientBridgeCompanionDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.GameClientBridgeSnapshotIngestRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Type != "companion.health" && request.Type != "bridge.diagnostics" {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", []string{"diagnostic snapshot type is invalid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshot, err := h.core.UploadGameClientBridgeSnapshot(request.ToDomain())
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeSnapshotIngestFromDomain(snapshot))
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/dto"
|
||||||
|
"browser.local/platform/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverGameClientBridgeStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.core.GetGameClientBridgeStatusForSession(bearerToken(r), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.GameClientBridgeStatusFromDomain(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverGameClientBridgeCommands(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
commands, err := h.core.ListGameClientBridgeCommandsForSession(bearerToken(r), domain.GameClientBridgeCommandFilter{ServerInstanceID: r.PathValue("id"), ProfileKey: r.URL.Query().Get("profileKey"), State: domain.GameClientBridgeCommandState(r.URL.Query().Get("state")), CommandType: r.URL.Query().Get("commandType")})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.GameClientBridgeCommandsFromDomain(commands))
|
||||||
|
case http.MethodPost:
|
||||||
|
request, err := decodeJSON[dto.GameClientBridgeQueueRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command, err := h.core.QueueGameClientBridgeCommandForSession(bearerToken(r), request.ToDomain(instance.ID, instance.PluginID))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeCommandFromDomain(command))
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
|
||||||
|
writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverGameClientBridgeCommandDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command, err := h.core.GetGameClientBridgeCommandForSession(bearerToken(r), r.PathValue("commandId"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if command.ServerInstanceID != r.PathValue("id") {
|
||||||
|
writeServiceError(w, repo.ErrNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.GameClientBridgeCommandFromDomain(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverGameClientBridgeCommandCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.GameClientBridgeCancelRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command, err := h.core.GetGameClientBridgeCommandForSession(bearerToken(r), r.PathValue("commandId"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if command.ServerInstanceID != r.PathValue("id") {
|
||||||
|
writeServiceError(w, repo.ErrNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancelled, err := h.core.CancelGameClientBridgeCommandForSession(bearerToken(r), request.ToDomain(command.ID))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.GameClientBridgeCancelFromDomain(cancelled))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverGameClientBridgeSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, err := optionalPositiveInt(r.URL.Query().Get("limit"))
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid snapshot limit", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var observedAfter time.Time
|
||||||
|
if value := r.URL.Query().Get("observedAfter"); value != "" {
|
||||||
|
observedAfter, err = time.Parse(time.RFC3339Nano, value)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid observedAfter timestamp", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query := dto.GameClientBridgeSnapshotQuery{ProfileKey: r.URL.Query().Get("profileKey"), Type: r.URL.Query().Get("type"), StreamKey: r.URL.Query().Get("streamKey"), ObservedAfter: observedAfter, Limit: limit}
|
||||||
|
snapshots, err := h.core.QueryGameClientBridgeSnapshotsForSession(bearerToken(r), query.ToDomain(instance.ID, instance.PluginID))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.GameClientBridgeSnapshotsFromDomain(snapshots))
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalPositiveInt(value string) (int, error) {
|
||||||
|
if value == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return strconv.Atoi(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/dto"
|
||||||
|
"browser.local/platform/repo"
|
||||||
|
"browser.local/platform/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gameClientBridgeCompanionCore struct {
|
||||||
|
service.Core
|
||||||
|
command domain.GameClientBridgeCommand
|
||||||
|
snapshot domain.GameClientBridgeSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (core *gameClientBridgeCompanionCore) ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||||
|
return []domain.GameClientBridgeCommand{domain.CopyGameClientBridgeCommand(core.command)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (core *gameClientBridgeCompanionCore) AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
return domain.CopyGameClientBridgeCommand(core.command), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (core *gameClientBridgeCompanionCore) CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
value := domain.CopyGameClientBridgeCommand(core.command)
|
||||||
|
value.State = domain.GameClientBridgeCommandSucceeded
|
||||||
|
value.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "done", CompletedBy: "internal-session", CompletedAt: time.Now().UTC()}
|
||||||
|
value.CompletedAt = value.Result.CompletedAt
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (core *gameClientBridgeCompanionCore) UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
|
||||||
|
return domain.CopyGameClientBridgeSnapshot(core.snapshot), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
||||||
|
store := repo.NewMemoryStore()
|
||||||
|
coreService := service.NewCoreService(store)
|
||||||
|
if err := coreService.SeedLocalPlatformAdmin(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plugin := validGamePluginRequest().ToDomain()
|
||||||
|
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
|
||||||
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||||
|
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
|
||||||
|
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client", DisplayName: "SCUM Client", Version: "1.0.0", RepositoryURL: "https://github.com/example/scum-client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum-client"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum-client", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||||
|
plugin.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: 3600, MaxPayloadBytes: 4096}}, 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}}
|
||||||
|
if _, err := coreService.CreateGamePlugin(plugin); err != nil {
|
||||||
|
t.Fatalf("create bridge plugin: %v", err)
|
||||||
|
}
|
||||||
|
endpoint := validRunEndpointRequest().ToDomain()
|
||||||
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||||
|
if _, err := coreService.CreateRunEndpoint(endpoint); err != nil {
|
||||||
|
t.Fatalf("create run endpoint: %v", err)
|
||||||
|
}
|
||||||
|
router := NewTestRouterWithCore(coreService)
|
||||||
|
adminSession := createAdminSession(t, router)
|
||||||
|
if _, err := coreService.CreateServerInstanceForSession(adminSession, domain.ServerInstance{ID: "server-bridge", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Bridge Server"}); err != nil {
|
||||||
|
t.Fatalf("create bridge server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getJSONWithAuth[dto.GameClientBridgeStatusResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge", adminSession)
|
||||||
|
if status.ServerInstanceID != "server-bridge" || status.PluginID != "server.scum" || status.Available || status.Profiles == nil {
|
||||||
|
t.Fatalf("unexpected bridge status: %#v", status)
|
||||||
|
}
|
||||||
|
if len(status.Profiles) != 1 || len(status.Profiles[0].QueryTemplateKeys) != 1 || status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
|
t.Fatalf("bridge status did not safely expose query template availability: %#v", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
queue := dto.GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-1", ExpiresAt: time.Now().UTC().Add(time.Hour)}
|
||||||
|
queuedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", queue, adminSession)
|
||||||
|
assertStatus(t, queuedRecorder, http.StatusAccepted)
|
||||||
|
queued := decodeBody[dto.GameClientBridgeCommandResponse](t, queuedRecorder)
|
||||||
|
if queued.ID == "" || queued.CommandType != queue.CommandType || queued.State != "pending" {
|
||||||
|
t.Fatalf("unexpected queued command: %#v", queued)
|
||||||
|
}
|
||||||
|
undeclared := queue
|
||||||
|
undeclared.CommandType = "undeclared.command"
|
||||||
|
undeclared.IdempotencyKey = "undeclared-1"
|
||||||
|
undeclaredRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", undeclared, adminSession)
|
||||||
|
assertErrorResponse(t, undeclaredRecorder, http.StatusBadRequest, errorCodeValidation)
|
||||||
|
|
||||||
|
commands := getJSONWithAuth[dto.GameClientBridgeCommandListResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/commands?state=pending", adminSession)
|
||||||
|
if commands.Count != 1 || commands.Items[0].ID != queued.ID {
|
||||||
|
t.Fatalf("unexpected command list: %#v", commands)
|
||||||
|
}
|
||||||
|
detail := getJSONWithAuth[dto.GameClientBridgeCommandResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/commands/"+queued.ID, adminSession)
|
||||||
|
if detail.ID != queued.ID {
|
||||||
|
t.Fatalf("unexpected command detail: %#v", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands/"+queued.ID+"/cancel", dto.GameClientBridgeCancelRequest{Reason: "operator requested"}, adminSession)
|
||||||
|
assertStatus(t, cancelRecorder, http.StatusOK)
|
||||||
|
cancelled := decodeBody[dto.GameClientBridgeCancelResponse](t, cancelRecorder)
|
||||||
|
if cancelled.State != "cancelled" || cancelled.CommandID != queued.ID {
|
||||||
|
t.Fatalf("unexpected cancellation: %#v", cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots := getJSONWithAuth[dto.GameClientBridgeSnapshotListResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/snapshots?limit=20", adminSession)
|
||||||
|
if snapshots.Count != 0 || snapshots.Items == nil {
|
||||||
|
t.Fatalf("unexpected snapshot list: %#v", snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, serialized := range []string{queued.ResultSummary, status.Reason} {
|
||||||
|
if strings.Contains(strings.ToLower(serialized), "token") || strings.Contains(strings.ToLower(serialized), "secret") {
|
||||||
|
t.Fatalf("safe operator response leaked sensitive text: %q", serialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeCompanionRoutesAreComponentSessionMediated(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
core := &gameClientBridgeCompanionCore{
|
||||||
|
Core: service.NewCoreService(repo.NewMemoryStore()),
|
||||||
|
command: domain.GameClientBridgeCommand{ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, State: domain.GameClientBridgeCommandClaimed, Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", FencingToken: 9, ClaimedAt: now, LeaseExpiresAt: now.Add(time.Minute)}, ExpiresAt: now.Add(time.Hour)},
|
||||||
|
snapshot: domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, SourceSessionID: "internal-source-session", CreatedAt: now, ExpiresAt: now.Add(time.Hour)},
|
||||||
|
}
|
||||||
|
router := NewAuthorizedRouterWithCore(core)
|
||||||
|
|
||||||
|
claim := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/claim", dto.GameClientBridgeClaimRequest{SessionToken: "component-token", Limit: 5})
|
||||||
|
assertStatus(t, claim, http.StatusOK)
|
||||||
|
if strings.Contains(claim.Body.String(), "internal-session-secret") || strings.Contains(claim.Body.String(), "internal-installation") {
|
||||||
|
t.Fatalf("claim response leaked component identity: %s", claim.Body.String())
|
||||||
|
}
|
||||||
|
claimed := decodeBody[dto.GameClientBridgeClaimResponse](t, claim)
|
||||||
|
if claimed.Count != 1 || claimed.Items[0].FencingToken != 9 {
|
||||||
|
t.Fatalf("unexpected claim response: %#v", claimed)
|
||||||
|
}
|
||||||
|
|
||||||
|
ack := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/ack", dto.GameClientBridgeAckRequest{SessionToken: "component-token", FencingToken: 9})
|
||||||
|
assertStatus(t, ack, http.StatusOK)
|
||||||
|
result := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/result", dto.GameClientBridgeResultRequest{SessionToken: "component-token", FencingToken: 9, Status: "succeeded", Summary: "done"})
|
||||||
|
assertStatus(t, result, http.StatusOK)
|
||||||
|
if strings.Contains(result.Body.String(), "internal-session") {
|
||||||
|
t.Fatalf("result response leaked completing session: %s", result.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotRequest := dto.GameClientBridgeSnapshotIngestRequest{SessionToken: "component-token", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: now, Payload: map[string]any{"healthy": true}, KeepForSeconds: 3600}
|
||||||
|
snapshot := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/snapshots", snapshotRequest)
|
||||||
|
assertStatus(t, snapshot, http.StatusAccepted)
|
||||||
|
if strings.Contains(snapshot.Body.String(), "internal-source-session") || strings.Contains(snapshot.Body.String(), "component-token") {
|
||||||
|
t.Fatalf("snapshot response leaked component material: %s", snapshot.Body.String())
|
||||||
|
}
|
||||||
|
diagnostic := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/diagnostics", snapshotRequest)
|
||||||
|
assertStatus(t, diagnostic, http.StatusAccepted)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"browser.local/platform/dto"
|
"browser.local/platform/dto"
|
||||||
"browser.local/platform/repo"
|
"browser.local/platform/repo"
|
||||||
@@ -60,11 +61,18 @@ func writeMethodNotAllowed(w http.ResponseWriter, allow string) {
|
|||||||
|
|
||||||
func writeServiceError(w http.ResponseWriter, err error) {
|
func writeServiceError(w http.ResponseWriter, err error) {
|
||||||
var validationErr validator.ValidationError
|
var validationErr validator.ValidationError
|
||||||
|
var forbiddenErr service.ForbiddenError
|
||||||
switch {
|
switch {
|
||||||
case errors.As(err, &validationErr):
|
case errors.As(err, &validationErr):
|
||||||
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", validationErr.Violations)
|
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", validationErr.Violations)
|
||||||
case errors.Is(err, service.ErrUnauthorized):
|
case errors.Is(err, service.ErrUnauthorized):
|
||||||
writeAPIError(w, http.StatusUnauthorized, errorCodeUnauthorized, "authentication required", nil)
|
writeAPIError(w, http.StatusUnauthorized, errorCodeUnauthorized, "authentication required", nil)
|
||||||
|
case errors.As(err, &forbiddenErr):
|
||||||
|
message := strings.TrimSpace(forbiddenErr.Reason)
|
||||||
|
if message == "" {
|
||||||
|
message = "account is not allowed to access this resource"
|
||||||
|
}
|
||||||
|
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, message, nil)
|
||||||
case errors.Is(err, service.ErrForbidden):
|
case errors.Is(err, service.ErrForbidden):
|
||||||
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, "account is not allowed to access this resource", nil)
|
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, "account is not allowed to access this resource", nil)
|
||||||
case errors.Is(err, repo.ErrDuplicate):
|
case errors.Is(err, repo.ErrDuplicate):
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail)
|
mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail)
|
||||||
mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics)
|
mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics)
|
||||||
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
||||||
|
mux.HandleFunc("/api/v1/production/capacity", h.productionCapacity)
|
||||||
|
mux.HandleFunc("/api/v1/production/capacity/admission", h.productionCapacityAdmission)
|
||||||
|
mux.HandleFunc("/api/v1/alerts", h.alerts)
|
||||||
|
mux.HandleFunc("/api/v1/alerts/{id}/acknowledge", h.alertAcknowledge)
|
||||||
|
mux.HandleFunc("/api/v1/alerts/{id}/resolve", h.alertResolve)
|
||||||
|
mux.HandleFunc("/api/v1/alerts/{id}/retry", h.alertRetry)
|
||||||
|
mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles)
|
||||||
|
mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction)
|
||||||
|
mux.HandleFunc("/api/v1/ai/config-diffs", h.aiConfigDiffs)
|
||||||
|
mux.HandleFunc("/api/v1/ai/config-diffs/{id}/approve", h.aiConfigDiffApprove)
|
||||||
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
||||||
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
||||||
mux.HandleFunc("/api/v1/backups", h.backups)
|
mux.HandleFunc("/api/v1/backups", h.backups)
|
||||||
@@ -74,6 +84,11 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/retry", h.serverClientManagerRetry)
|
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/retry", h.serverClientManagerRetry)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/revoke-session", h.serverClientManagerRevokeSession)
|
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/revoke-session", h.serverClientManagerRevokeSession)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/uninstall", h.serverClientManagerUninstall)
|
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/uninstall", h.serverClientManagerUninstall)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge", h.serverGameClientBridgeStatus)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands", h.serverGameClientBridgeCommands)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}/cancel", h.serverGameClientBridgeCommandCancel)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}", h.serverGameClientBridgeCommandDetail)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
|
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/install", h.serverDependenciesInstall)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
||||||
@@ -123,6 +138,290 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/audit-events/{id}", h.auditEventDetail)
|
mux.HandleFunc("/api/v1/audit-events/{id}", h.auditEventDetail)
|
||||||
mux.HandleFunc("/api/v1/client-managers/register", h.clientManagerRegister)
|
mux.HandleFunc("/api/v1/client-managers/register", h.clientManagerRegister)
|
||||||
mux.HandleFunc("/api/v1/client-managers/heartbeat", h.clientManagerHeartbeat)
|
mux.HandleFunc("/api/v1/client-managers/heartbeat", h.clientManagerHeartbeat)
|
||||||
|
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/claim", h.gameClientBridgeCompanionClaim)
|
||||||
|
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/ack", h.gameClientBridgeCompanionAck)
|
||||||
|
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/result", h.gameClientBridgeCompanionResult)
|
||||||
|
mux.HandleFunc("/api/v1/game-client-bridge/companion/snapshots", h.gameClientBridgeCompanionSnapshot)
|
||||||
|
mux.HandleFunc("/api/v1/game-client-bridge/companion/diagnostics", h.gameClientBridgeCompanionDiagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// productionCapacity godoc
|
||||||
|
// @Summary Get production capacity governance state
|
||||||
|
// @Description Returns bounded Run endpoint capacity and durable pressure counts visible to the current operator.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} dto.ProductionCapacitySummaryResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/production/capacity [get]
|
||||||
|
func (h *coreHandlers) productionCapacity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary, err := h.core.GetProductionCapacityForSession(bearerToken(r))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.ProductionCapacityFromDomain(summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
// productionCapacityAdmission godoc
|
||||||
|
// @Summary Check production capacity admission
|
||||||
|
// @Description Evaluates endpoint heartbeat, capability, durable jobs, and bounded backlog pressure without dispatching work.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body dto.CapacityAdmissionRequest true "Capacity admission request"
|
||||||
|
// @Success 200 {object} dto.CapacityAdmissionDecisionResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/production/capacity/admission [post]
|
||||||
|
func (h *coreHandlers) productionCapacityAdmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.CapacityAdmissionRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
decision, err := h.core.CheckCapacityAdmissionForSession(bearerToken(r), request.ToDomain())
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.CapacityDecisionFromDomain(decision))
|
||||||
|
}
|
||||||
|
|
||||||
|
// alerts godoc
|
||||||
|
// @Summary List durable production alerts
|
||||||
|
// @Description Lists alerts visible to the current operator with optional safe state/source/severity filters.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} dto.AlertListResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/alerts [get]
|
||||||
|
func (h *coreHandlers) alerts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alerts, err := h.core.ListAlertsForSession(bearerToken(r), domain.AlertFilter{State: domain.AlertState(r.URL.Query().Get("state")), SourceKind: r.URL.Query().Get("sourceKind"), SourceID: r.URL.Query().Get("sourceId"), Severity: domain.AlertSeverity(r.URL.Query().Get("severity"))})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.AlertListFromDomain(alerts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertAcknowledge godoc
|
||||||
|
// @Summary Acknowledge a durable alert
|
||||||
|
// @Description Persists acknowledgement actor, timestamp, and linked audit evidence for one alert.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Alert ID"
|
||||||
|
// @Param body body dto.AlertAcknowledgeRequest true "Acknowledgement request"
|
||||||
|
// @Success 200 {object} dto.AlertResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/alerts/{id}/acknowledge [post]
|
||||||
|
func (h *coreHandlers) alertAcknowledge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.AlertAcknowledgeRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alert, err := h.core.AcknowledgeAlertForSession(bearerToken(r), domain.AlertAcknowledgeRequest{AlertID: r.PathValue("id"), Note: request.Note})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.AlertFromDomain(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertResolve godoc
|
||||||
|
// @Summary Resolve a durable alert
|
||||||
|
// @Description Resolves one alert with a safe operator note and linked audit evidence.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Alert ID"
|
||||||
|
// @Param body body dto.AlertResolveRequest true "Resolution request"
|
||||||
|
// @Success 200 {object} dto.AlertResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/alerts/{id}/resolve [post]
|
||||||
|
func (h *coreHandlers) alertResolve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.AlertResolveRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alert, err := h.core.ResolveAlertForSession(bearerToken(r), domain.AlertResolveRequest{AlertID: r.PathValue("id"), Note: request.Note})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.AlertFromDomain(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertRetry godoc
|
||||||
|
// @Summary Retry one durable alert source
|
||||||
|
// @Description Retries only the bounded source represented by an alert and preserves idempotency.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Alert ID"
|
||||||
|
// @Param body body dto.AlertRetryRequest true "Scoped retry request"
|
||||||
|
// @Success 202 {object} dto.AlertRetryResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/alerts/{id}/retry [post]
|
||||||
|
func (h *coreHandlers) alertRetry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.AlertRetryRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.core.RetryAlertForSession(bearerToken(r), domain.AlertRetryRequest{AlertID: r.PathValue("id"), IdempotencyKey: request.IdempotencyKey})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.AlertRetryFromDomain(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluginLifecycles godoc
|
||||||
|
// @Summary List server-bound plugin lifecycle state
|
||||||
|
// @Description Lists durable plugin installation, desired/current state, compatibility, dependency, job, alert, and audit metadata.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} dto.PluginLifecycleListResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/plugin-lifecycles [get]
|
||||||
|
func (h *coreHandlers) pluginLifecycles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.core.ListPluginLifecyclesForSession(bearerToken(r), domain.PluginLifecycleFilter{PluginID: r.URL.Query().Get("pluginId"), ServerInstanceID: r.URL.Query().Get("serverInstanceId"), CurrentState: domain.PluginLifecycleState(r.URL.Query().Get("currentState"))})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.PluginLifecycleListFromDomain(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluginLifecycleAction godoc
|
||||||
|
// @Summary Dispatch a platform-mediated plugin lifecycle action
|
||||||
|
// @Description Runs compatibility and capacity gates before creating one durable bounded Run job.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param pluginId path string true "Plugin ID"
|
||||||
|
// @Param body body dto.PluginLifecycleActionRequest true "Plugin lifecycle action"
|
||||||
|
// @Success 202 {object} dto.PluginLifecycleActionResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/plugin-lifecycles/{pluginId}/actions [post]
|
||||||
|
func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.PluginLifecycleActionRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.core.RunPluginLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("pluginId")))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.PluginLifecycleResultFromDomain(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiConfigDiffs godoc
|
||||||
|
// @Summary List reviewable AI config diffs
|
||||||
|
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} dto.AIConfigDiffListResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/ai/config-diffs [get]
|
||||||
|
func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.core.ListAIConfigDiffsForSession(bearerToken(r), domain.AIConfigDiffFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), PluginID: r.URL.Query().Get("pluginId"), State: domain.AIConfigDiffState(r.URL.Query().Get("state"))})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.AIConfigDiffListFromDomain(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiConfigDiffApprove godoc
|
||||||
|
// @Summary Approve one reviewable AI config diff
|
||||||
|
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
|
||||||
|
// @Tags production-operations
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "AI config diff ID"
|
||||||
|
// @Param body body dto.AIConfigDiffApprovalRequest true "AI config diff approval"
|
||||||
|
// @Success 202 {object} dto.AIConfigDiffApprovalResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/ai/config-diffs/{id}/approve [post]
|
||||||
|
func (h *coreHandlers) aiConfigDiffApprove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.AIConfigDiffApprovalRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.core.ApproveAIConfigDiffForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.AIConfigDiffApprovalFromDomain(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
// authRegister godoc
|
// authRegister godoc
|
||||||
@@ -554,7 +853,7 @@ func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// aiProviderDetail godoc
|
// aiProviderDetail godoc
|
||||||
// @Summary Get AI provider
|
// @Summary Get AI provider
|
||||||
// @Description Returns one platform-managed AI provider by ID without raw key material.
|
// @Description Returns one platform-managed AI provider by ID without raw key or base URL material.
|
||||||
// @Tags ai-providers
|
// @Tags ai-providers
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "AI provider ID"
|
// @Param id path string true "AI provider ID"
|
||||||
@@ -589,6 +888,9 @@ func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
update := request.ToDomain(r.PathValue("id"), existing.Status)
|
update := request.ToDomain(r.PathValue("id"), existing.Status)
|
||||||
|
if strings.TrimSpace(update.BaseURL) == "" {
|
||||||
|
update.BaseURL = existing.BaseURL
|
||||||
|
}
|
||||||
if strings.TrimSpace(update.APIKeyRef) == "" {
|
if strings.TrimSpace(update.APIKeyRef) == "" {
|
||||||
update.APIKeyRef = existing.APIKeyRef
|
update.APIKeyRef = existing.APIKeyRef
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -391,6 +392,37 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreAPIRunDistributionDenialNamesMissingPluginPermission(t *testing.T) {
|
||||||
|
router := newTestRouter()
|
||||||
|
adminSession := createAdminSession(t, router)
|
||||||
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest())
|
||||||
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||||
|
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||||
|
ID: "server-plugin-denied",
|
||||||
|
PluginID: "game.example",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Name: "Plugin Denied",
|
||||||
|
}, adminSession)
|
||||||
|
|
||||||
|
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+server.ID+"/run/generate", dto.RunDistributionGenerateRequest{
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
IdempotencyKey: "missing-plugin-permission",
|
||||||
|
}, adminSession)
|
||||||
|
assertStatus(t, recorder, http.StatusForbidden)
|
||||||
|
body := decodeBody[dto.ErrorResponse](t, recorder)
|
||||||
|
if body.Code != errorCodeForbidden || body.Message != "plugin does not declare required permission: server.run.distribution" {
|
||||||
|
t.Fatalf("expected plugin permission denial, got %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependenciesRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/dependencies", "", adminSession)
|
||||||
|
assertStatus(t, dependenciesRecorder, http.StatusForbidden)
|
||||||
|
dependenciesBody := decodeBody[dto.ErrorResponse](t, dependenciesRecorder)
|
||||||
|
if dependenciesBody.Code != errorCodeForbidden || dependenciesBody.Message != "plugin does not declare required permission: server.dependencies.manage" {
|
||||||
|
t.Fatalf("expected dependency permission denial, got %+v", dependenciesBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreAPIErrorResponses(t *testing.T) {
|
func TestCoreAPIErrorResponses(t *testing.T) {
|
||||||
router := newTestRouter()
|
router := newTestRouter()
|
||||||
adminSession := createAdminSession(t, router)
|
adminSession := createAdminSession(t, router)
|
||||||
@@ -817,7 +849,7 @@ func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
|||||||
assertErrorResponse(t, forbiddenDetail, http.StatusForbidden, errorCodeForbidden)
|
assertErrorResponse(t, forbiddenDetail, http.StatusForbidden, errorCodeForbidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
func TestAIProviderAPIResponseDoesNotExposeRawSecretFields(t *testing.T) {
|
||||||
router := newTestRouter()
|
router := newTestRouter()
|
||||||
adminSession := createAdminSession(t, router)
|
adminSession := createAdminSession(t, router)
|
||||||
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
|
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
|
||||||
@@ -836,6 +868,9 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
|||||||
if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true {
|
if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true {
|
||||||
t.Fatalf("expected API key presence only, got %+v", body)
|
t.Fatalf("expected API key presence only, got %+v", body)
|
||||||
}
|
}
|
||||||
|
if _, exists := body["baseUrl"]; exists || body["baseUrlConfigured"] != true {
|
||||||
|
t.Fatalf("expected base URL presence only, got %+v", body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAIProviderManagementAPI(t *testing.T) {
|
func TestAIProviderManagementAPI(t *testing.T) {
|
||||||
@@ -844,13 +879,13 @@ func TestAIProviderManagementAPI(t *testing.T) {
|
|||||||
createAIProviderFixture(t, router, adminSession)
|
createAIProviderFixture(t, router, adminSession)
|
||||||
|
|
||||||
update := validAIProviderUpdateRequest()
|
update := validAIProviderUpdateRequest()
|
||||||
|
update.BaseURL = ""
|
||||||
updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
|
updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
|
||||||
assertStatus(t, updatedRecorder, http.StatusOK)
|
assertStatus(t, updatedRecorder, http.StatusOK)
|
||||||
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
|
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
|
||||||
if updated.Name != "OpenAI Relay" || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
|
if updated.Name != "OpenAI Relay" || !updated.BaseURLConfigured || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
|
||||||
t.Fatalf("unexpected updated provider: %+v", updated)
|
t.Fatalf("unexpected updated provider: %+v", updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
statusRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
|
statusRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
|
||||||
assertStatus(t, statusRecorder, http.StatusOK)
|
assertStatus(t, statusRecorder, http.StatusOK)
|
||||||
disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder)
|
disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder)
|
||||||
@@ -861,7 +896,7 @@ func TestAIProviderManagementAPI(t *testing.T) {
|
|||||||
testRecorder := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
|
testRecorder := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
|
||||||
assertStatus(t, testRecorder, http.StatusOK)
|
assertStatus(t, testRecorder, http.StatusOK)
|
||||||
testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder)
|
testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder)
|
||||||
if testResult.Success || testResult.Mode != "metadata" {
|
if testResult.Success || testResult.Mode != "provider" {
|
||||||
t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult)
|
t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1408,6 +1443,56 @@ func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(t *testing.T) {
|
||||||
|
router := newTestRouter()
|
||||||
|
adminSession := createAdminSession(t, router)
|
||||||
|
serverID := createRuntimeAPIFixtures(t, router, adminSession)
|
||||||
|
createAIProviderFixture(t, router, adminSession)
|
||||||
|
|
||||||
|
capacity := getJSONWithAuth[dto.ProductionCapacitySummaryResponse](t, router, "/api/v1/production/capacity", adminSession)
|
||||||
|
if len(capacity.Endpoints) == 0 {
|
||||||
|
t.Fatalf("expected persisted capacity endpoints, got %+v", capacity)
|
||||||
|
}
|
||||||
|
decision := postOKJSONWithAuth[dto.CapacityAdmissionDecisionResponse](t, router, "/api/v1/production/capacity/admission", dto.CapacityAdmissionRequest{ServerInstanceID: serverID, Capability: domain.JobCapabilityConfigWrite, IdempotencyKey: "api-capacity-check"}, adminSession)
|
||||||
|
if decision.Accepted || decision.AlertID == "" || decision.AuditEventID == "" {
|
||||||
|
t.Fatalf("expected stale endpoint admission to create durable evidence, got %+v", decision)
|
||||||
|
}
|
||||||
|
alerts := getJSONWithAuth[dto.AlertListResponse](t, router, "/api/v1/alerts?state=active", adminSession)
|
||||||
|
if alerts.Count == 0 {
|
||||||
|
t.Fatalf("expected durable alert list, got %+v", alerts)
|
||||||
|
}
|
||||||
|
acknowledged := postOKJSONWithAuth[dto.AlertResponse](t, router, "/api/v1/alerts/"+decision.AlertID+"/acknowledge", dto.AlertAcknowledgeRequest{Note: "operator review"}, adminSession)
|
||||||
|
if acknowledged.State != string(domain.AlertStateAcknowledged) {
|
||||||
|
t.Fatalf("expected acknowledged state, got %+v", acknowledged)
|
||||||
|
}
|
||||||
|
resolved := postOKJSONWithAuth[dto.AlertResponse](t, router, "/api/v1/alerts/"+decision.AlertID+"/resolve", dto.AlertResolveRequest{Note: "review complete"}, adminSession)
|
||||||
|
if resolved.State != string(domain.AlertStateResolved) {
|
||||||
|
t.Fatalf("expected resolved state, got %+v", resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-diff", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
||||||
|
if invocation.ConfigRecommendation == nil || invocation.ConfigRecommendation.DiffID == "" {
|
||||||
|
t.Fatalf("expected persisted AI config diff, got %+v", invocation)
|
||||||
|
}
|
||||||
|
diffs := getJSONWithAuth[dto.AIConfigDiffListResponse](t, router, "/api/v1/ai/config-diffs?serverInstanceId="+serverID, adminSession)
|
||||||
|
if diffs.Count != 1 || diffs.Items[0].State != string(domain.AIConfigDiffStatePending) {
|
||||||
|
t.Fatalf("expected one pending AI diff, got %+v", diffs)
|
||||||
|
}
|
||||||
|
approvalRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-diffs/"+invocation.ConfigRecommendation.DiffID+"/approve", dto.AIConfigDiffApprovalRequest{IdempotencyKey: "api-ai-diff-approve"}, adminSession)
|
||||||
|
assertStatus(t, approvalRecorder, http.StatusAccepted)
|
||||||
|
approval := decodeBody[dto.AIConfigDiffApprovalResponse](t, approvalRecorder)
|
||||||
|
if approval.Preview.State != string(domain.AIConfigDiffStateApproved) || approval.Dispatch.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||||
|
t.Fatalf("expected approved diff with config write job, got %+v", approval)
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence := fmt.Sprintf("%+v %+v %+v %+v %+v %+v", capacity, decision, alerts, resolved, diffs, approval)
|
||||||
|
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
||||||
|
if strings.Contains(evidence, forbidden) {
|
||||||
|
t.Fatalf("production governance response leaked forbidden fragment %q: %s", forbidden, evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestRouter() http.Handler {
|
func newTestRouter() http.Handler {
|
||||||
core := service.NewCoreService(repo.NewMemoryStore())
|
core := service.NewCoreService(repo.NewMemoryStore())
|
||||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||||
@@ -1613,6 +1698,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
|||||||
"process.start",
|
"process.start",
|
||||||
"process.stop",
|
"process.stop",
|
||||||
"logs.read",
|
"logs.read",
|
||||||
|
domain.JobCapabilityConfigWrite,
|
||||||
domain.JobCapabilityRunSelfUpdate,
|
domain.JobCapabilityRunSelfUpdate,
|
||||||
domain.JobCapabilityDependenciesCheck,
|
domain.JobCapabilityDependenciesCheck,
|
||||||
domain.JobCapabilityDependenciesInstall,
|
domain.JobCapabilityDependenciesInstall,
|
||||||
@@ -1771,7 +1857,8 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra
|
|||||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}},
|
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||||
|
ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
|
|||||||
if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil {
|
if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := core.ConfigureAIProviderMode(cfg.AIProviderMode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if strings.TrimSpace(cfg.BootstrapAdminPassword) != "" {
|
if strings.TrimSpace(cfg.BootstrapAdminPassword) != "" {
|
||||||
if err := core.SeedPlatformAdmin(cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword); err != nil {
|
if err := core.SeedPlatformAdmin(cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+20
-2
@@ -25,6 +25,8 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
|||||||
| Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` |
|
| Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` |
|
||||||
| Audit events | `GET /api/v1/audit-events`, `POST /api/v1/audit-events` | `GET /api/v1/audit-events/{id}` | `AuditEventCreateRequest`, `AuditEventResponse`, `AuditEventListResponse` |
|
| Audit events | `GET /api/v1/audit-events`, `POST /api/v1/audit-events` | `GET /api/v1/audit-events/{id}` | `AuditEventCreateRequest`, `AuditEventResponse`, `AuditEventListResponse` |
|
||||||
|
|
||||||
|
Client Manager lifecycle routes are grouped under the server instance and return only the safe installation projection: `GET /api/v1/server-instances/{id}/client-managers`, `GET .../{profileKey}`, and typed `POST` routes for `deploy`, `control`, `update`, `retry`, `revoke-session`, and confirmed `uninstall`. Component-only `POST /api/v1/client-managers/register` and `/heartbeat` use the separate signed component identity/session contract. Run-only input/chunk routes are fenced by the active Run job lease. None of these DTOs return raw component keys, bearer sessions, secret refs/values, host paths, PIDs, sockets, or endpoint addresses.
|
||||||
|
|
||||||
## Implemented Query Filters
|
## Implemented Query Filters
|
||||||
|
|
||||||
- `GET /api/v1/users?status=active`
|
- `GET /api/v1/users?status=active`
|
||||||
@@ -83,12 +85,13 @@ Config write and file dispatch responses expose only logical target keys, scoped
|
|||||||
## Implemented AI Provider Management Actions
|
## Implemented AI Provider Management Actions
|
||||||
|
|
||||||
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
|
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
|
||||||
- `POST /api/v1/ai-providers/{id}/test`: run local metadata validation using `AIProviderTestResponse`; this does not call external AI services.
|
- `POST /api/v1/ai-providers/{id}/test`: invoke the configured provider client with a bounded health request and return only a redacted `AIProviderTestResponse`.
|
||||||
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
|
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
|
||||||
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
|
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
|
||||||
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
|
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
|
||||||
|
|
||||||
AI invocation is platform-mediated. Tests and local verification use a deterministic mock provider client; live external provider calls are deferred behind the same interface and are not required for this change. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions are recommendations only and never dispatch run-side writes directly.
|
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions persist an expiring diff and never dispatch run-side writes before separate approval.
|
||||||
|
AI Provider management responses also return only `baseUrlConfigured` and `apiKeyConfigured`; an empty base URL or secret reference in an update preserves the Platform-owned value instead of round-tripping it through the browser.
|
||||||
|
|
||||||
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a controlled secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a controlled secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
||||||
|
|
||||||
@@ -108,6 +111,21 @@ Marketplace responses include game management plugin identity, version, display
|
|||||||
|
|
||||||
Marketplace state actions are metadata-only in this change. `install` and `enable` mark the registered plugin `installed`; `disable` marks it `disabled`. These actions do not download external packages, create run jobs, execute plugin code, write files, or contact external services.
|
Marketplace state actions are metadata-only in this change. `install` and `enable` mark the registered plugin `installed`; `disable` marks it `disabled`. These actions do not download external packages, create run jobs, execute plugin code, write files, or contact external services.
|
||||||
|
|
||||||
|
Marketplace catalog state remains separate from production lifecycle installations. Server-bound install/enable/disable/upgrade/rollback/retire operations use the production lifecycle routes below.
|
||||||
|
|
||||||
|
## Production Operations Governance
|
||||||
|
|
||||||
|
- `GET /api/v1/production/capacity`: return bounded endpoint capacity, durable job pressure, backlog counts, pressure codes, and active-alert count visible to the session.
|
||||||
|
- `POST /api/v1/production/capacity/admission`: evaluate server binding, endpoint heartbeat/capability, job limits, queue pressure, and spool pressure without dispatching work.
|
||||||
|
- `GET /api/v1/alerts`: list durable alerts with state/source/severity filters.
|
||||||
|
- `POST /api/v1/alerts/{id}/acknowledge`, `/resolve`, and `/retry`: persist one scoped alert transition or source retry with actor/audit evidence.
|
||||||
|
- `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations.
|
||||||
|
- `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, idempotency, and capacity before creating one durable Run job.
|
||||||
|
- `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session.
|
||||||
|
- `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job.
|
||||||
|
|
||||||
|
These responses expose logical IDs, counts, states, pressure codes, safe diagnostics, and job/audit links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material.
|
||||||
|
|
||||||
## Implemented Plugin Bridge Actions
|
## Implemented Plugin Bridge Actions
|
||||||
|
|
||||||
- `POST /api/v1/plugin-bridge/authorize`: accepts `PluginBridgeAuthorizeRequest` and returns whether an installed plugin page may use one declared bridge action with the effective route permissions.
|
- `POST /api/v1/plugin-bridge/authorize`: accepts `PluginBridgeAuthorizeRequest` and returns whether an installed plugin page may use one declared bridge action with the effective route permissions.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Config struct {
|
|||||||
BootstrapAdminEmail string
|
BootstrapAdminEmail string
|
||||||
BootstrapAdminPassword string
|
BootstrapAdminPassword string
|
||||||
SecretEnvelopeKey string
|
SecretEnvelopeKey string
|
||||||
|
AIProviderMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@@ -66,9 +67,17 @@ func Load() Config {
|
|||||||
BootstrapAdminEmail: strings.TrimSpace(os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL")),
|
BootstrapAdminEmail: strings.TrimSpace(os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL")),
|
||||||
BootstrapAdminPassword: os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD"),
|
BootstrapAdminPassword: os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD"),
|
||||||
SecretEnvelopeKey: os.Getenv("PLATFORM_SECRET_ENVELOPE_KEY"),
|
SecretEnvelopeKey: os.Getenv("PLATFORM_SECRET_ENVELOPE_KEY"),
|
||||||
|
AIProviderMode: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_AI_PROVIDER_MODE")), "live"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultString(value, fallback string) string {
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func loadLocalEnvFiles() {
|
func loadLocalEnvFiles() {
|
||||||
candidates := []string{".env", filepath.Join("platform", ".env")}
|
candidates := []string{".env", filepath.Join("platform", ".env")}
|
||||||
for _, path := range candidates {
|
for _, path := range candidates {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type AIConfigRecommendation struct {
|
|||||||
Key string
|
Key string
|
||||||
SuggestedConfig string
|
SuggestedConfig string
|
||||||
DiffSummary string
|
DiffSummary string
|
||||||
|
DiffID string
|
||||||
|
ExpiresAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIInvocationSafeError struct {
|
type AIInvocationSafeError struct {
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ const (
|
|||||||
JobCapabilityClientManagerUninstall = "client-manager.uninstall"
|
JobCapabilityClientManagerUninstall = "client-manager.uninstall"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ClientManagerCompanionConfigSchemaVersion = 1
|
||||||
|
|
||||||
type ClientManagerSessionStatus string
|
type ClientManagerSessionStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -246,6 +248,37 @@ type ClientManagerLifecycleInput struct {
|
|||||||
StopTimeoutSeconds int
|
StopTimeoutSeconds int
|
||||||
HealthConfirmationSeconds int
|
HealthConfirmationSeconds int
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
|
CompanionConfig *ClientManagerCompanionConfigInput
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerCompanionConfigInput struct {
|
||||||
|
SchemaVersion int
|
||||||
|
ConfigTemplateKey string
|
||||||
|
ConfigTemplateRef string
|
||||||
|
ConfigOutputRef string
|
||||||
|
ConfigSchemaRef string
|
||||||
|
ConfigFormat string
|
||||||
|
PlatformBaseURLSource string
|
||||||
|
InstallationID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
ArtifactID string
|
||||||
|
Version string
|
||||||
|
SourceRevision string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
KeyGeneration int
|
||||||
|
DeploymentGeneration int
|
||||||
|
Capabilities []string
|
||||||
|
RegistrationProof string
|
||||||
|
ProofMaterialSource string
|
||||||
|
ProofMaterialEnv string
|
||||||
|
SessionMode string
|
||||||
|
TLSPolicy string
|
||||||
|
HeartbeatIntervalSeconds int
|
||||||
|
CommandPollIntervalSeconds int
|
||||||
|
RequestTimeoutSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientManagerRegisterRequest struct {
|
type ClientManagerRegisterRequest struct {
|
||||||
@@ -336,6 +369,15 @@ func CopyClientManagerLifecycleView(value ClientManagerLifecycleView) ClientMana
|
|||||||
|
|
||||||
func CopyClientManagerLifecycleInput(value ClientManagerLifecycleInput) ClientManagerLifecycleInput {
|
func CopyClientManagerLifecycleInput(value ClientManagerLifecycleInput) ClientManagerLifecycleInput {
|
||||||
value.Arguments = CopyStringSlice(value.Arguments)
|
value.Arguments = CopyStringSlice(value.Arguments)
|
||||||
|
if value.CompanionConfig != nil {
|
||||||
|
companion := CopyClientManagerCompanionConfigInput(*value.CompanionConfig)
|
||||||
|
value.CompanionConfig = &companion
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyClientManagerCompanionConfigInput(value ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInput {
|
||||||
|
value.Capabilities = CopyStringSlice(value.Capabilities)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCopyClientManagerLifecycleInputDeepCopiesCompanionCapabilities(t *testing.T) {
|
||||||
|
original := ClientManagerLifecycleInput{
|
||||||
|
Arguments: []string{"--foreground"},
|
||||||
|
CompanionConfig: &ClientManagerCompanionConfigInput{
|
||||||
|
Capabilities: []string{"component.register", "game-client.bridge"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cloned := CopyClientManagerLifecycleInput(original)
|
||||||
|
cloned.Arguments[0] = "--changed"
|
||||||
|
cloned.CompanionConfig.Capabilities[0] = "changed"
|
||||||
|
|
||||||
|
if original.Arguments[0] != "--foreground" {
|
||||||
|
t.Fatalf("arguments were not deeply copied: %+v", original.Arguments)
|
||||||
|
}
|
||||||
|
if original.CompanionConfig.Capabilities[0] != "component.register" {
|
||||||
|
t.Fatalf("companion capabilities were not deeply copied: %+v", original.CompanionConfig.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type GameClientBridgeCommandState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GameClientBridgeCommandPending GameClientBridgeCommandState = "pending"
|
||||||
|
GameClientBridgeCommandClaimed GameClientBridgeCommandState = "claimed"
|
||||||
|
GameClientBridgeCommandSucceeded GameClientBridgeCommandState = "succeeded"
|
||||||
|
GameClientBridgeCommandFailed GameClientBridgeCommandState = "failed"
|
||||||
|
GameClientBridgeCommandCancelled GameClientBridgeCommandState = "cancelled"
|
||||||
|
GameClientBridgeCommandExpired GameClientBridgeCommandState = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameClientBridgeApprovalState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GameClientBridgeApprovalNotRequired GameClientBridgeApprovalState = "not_required"
|
||||||
|
GameClientBridgeApprovalPending GameClientBridgeApprovalState = "pending"
|
||||||
|
GameClientBridgeApprovalApproved GameClientBridgeApprovalState = "approved"
|
||||||
|
GameClientBridgeApprovalRejected GameClientBridgeApprovalState = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameClientBridgeApprovalLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GameClientBridgeApprovalLevelNone GameClientBridgeApprovalLevel = "none"
|
||||||
|
GameClientBridgeApprovalLevelOperator GameClientBridgeApprovalLevel = "operator"
|
||||||
|
GameClientBridgeApprovalLevelPlatformAdmin GameClientBridgeApprovalLevel = "platform-admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameClientBridgeCommandDeclaration struct {
|
||||||
|
Type string
|
||||||
|
Title string
|
||||||
|
Permission string
|
||||||
|
ApprovalLevel GameClientBridgeApprovalLevel
|
||||||
|
PayloadSchemaRef string
|
||||||
|
ResultSchemaRef string
|
||||||
|
TimeoutSeconds int
|
||||||
|
MaxPayloadBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotDeclaration struct {
|
||||||
|
Type string
|
||||||
|
SchemaVersion string
|
||||||
|
SchemaRef string
|
||||||
|
Retention GameClientBridgeRetention
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeQueryTemplateDeclaration struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
Permission string
|
||||||
|
Engine string
|
||||||
|
TransportKey string
|
||||||
|
TargetKey string
|
||||||
|
ParameterSchemaRef string
|
||||||
|
ResultSchemaRef string
|
||||||
|
MaxRows int
|
||||||
|
TimeoutSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgePageContract struct {
|
||||||
|
PageKey string
|
||||||
|
CommandTypes []string
|
||||||
|
SnapshotTypes []string
|
||||||
|
QueryTemplateKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCompanionDeclaration struct {
|
||||||
|
ProfileKey string
|
||||||
|
ConfigTemplateKey string
|
||||||
|
ConfigSchemaRef string
|
||||||
|
ConfigFormat string
|
||||||
|
PlatformBaseURLSource string
|
||||||
|
RegistrationProof string
|
||||||
|
ProofMaterialSource string
|
||||||
|
ProofMaterialEnv string
|
||||||
|
SessionMode string
|
||||||
|
TLSPolicy string
|
||||||
|
HeartbeatIntervalSeconds int
|
||||||
|
CommandPollIntervalSeconds int
|
||||||
|
RequestTimeoutSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeManifest struct {
|
||||||
|
Commands []GameClientBridgeCommandDeclaration
|
||||||
|
Snapshots []GameClientBridgeSnapshotDeclaration
|
||||||
|
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
||||||
|
Retention GameClientBridgeRetention
|
||||||
|
Pages []GameClientBridgePageContract
|
||||||
|
Companion GameClientBridgeCompanionDeclaration
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeResultStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GameClientBridgeResultSucceeded GameClientBridgeResultStatus = "succeeded"
|
||||||
|
GameClientBridgeResultFailed GameClientBridgeResultStatus = "failed"
|
||||||
|
GameClientBridgeResultCancelled GameClientBridgeResultStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameClientBridgeCommand struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
CommandType string
|
||||||
|
Payload map[string]any
|
||||||
|
IdempotencyKey string
|
||||||
|
Priority int
|
||||||
|
State GameClientBridgeCommandState
|
||||||
|
ApprovalState GameClientBridgeApprovalState
|
||||||
|
RequesterID string
|
||||||
|
Claim GameClientBridgeClaim
|
||||||
|
Cancellation GameClientBridgeCancellation
|
||||||
|
Result GameClientBridgeResult
|
||||||
|
AuditReferences []string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeClaim struct {
|
||||||
|
SessionID string
|
||||||
|
InstallationID string
|
||||||
|
DeploymentGeneration int
|
||||||
|
FencingToken uint64
|
||||||
|
LeaseExpiresAt time.Time
|
||||||
|
ClaimedAt time.Time
|
||||||
|
AcknowledgedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCancellation struct {
|
||||||
|
RequestedBy string
|
||||||
|
Reason string
|
||||||
|
CancelledAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeResult struct {
|
||||||
|
Status GameClientBridgeResultStatus
|
||||||
|
Summary string
|
||||||
|
Payload map[string]any
|
||||||
|
CompletedBy string
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshot struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
Type string
|
||||||
|
SchemaVersion string
|
||||||
|
StreamKey string
|
||||||
|
Sequence uint64
|
||||||
|
SourceSessionID string
|
||||||
|
ObservedAt time.Time
|
||||||
|
Payload map[string]any
|
||||||
|
Retention GameClientBridgeRetention
|
||||||
|
AuditReferences []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotStream struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
Type string
|
||||||
|
StreamKey string
|
||||||
|
LatestSequence uint64
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeRetention struct {
|
||||||
|
KeepForSeconds int
|
||||||
|
MaxRecords int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeAuditReference struct {
|
||||||
|
ID string
|
||||||
|
CommandID string
|
||||||
|
SnapshotID string
|
||||||
|
AuditEventID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCommandFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
State GameClientBridgeCommandState
|
||||||
|
RequesterID string
|
||||||
|
CommandType string
|
||||||
|
IdempotencyKey string
|
||||||
|
ExpiresBefore time.Time
|
||||||
|
CompletedBefore time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotStreamFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
Type string
|
||||||
|
StreamKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
Type string
|
||||||
|
StreamKey string
|
||||||
|
ObservedAfter time.Time
|
||||||
|
ExpiresBefore time.Time
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeQueueRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
CommandType string
|
||||||
|
Payload map[string]any
|
||||||
|
IdempotencyKey string
|
||||||
|
Priority int
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeClaimRequest struct {
|
||||||
|
SessionToken string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeAckRequest struct {
|
||||||
|
SessionToken string
|
||||||
|
CommandID string
|
||||||
|
FencingToken uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeResultRequest struct {
|
||||||
|
SessionToken string
|
||||||
|
CommandID string
|
||||||
|
FencingToken uint64
|
||||||
|
Status GameClientBridgeResultStatus
|
||||||
|
Summary string
|
||||||
|
Payload map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCancelRequest struct {
|
||||||
|
CommandID string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotIngestRequest struct {
|
||||||
|
SessionToken string
|
||||||
|
Type string
|
||||||
|
SchemaVersion string
|
||||||
|
StreamKey string
|
||||||
|
Sequence uint64
|
||||||
|
ObservedAt time.Time
|
||||||
|
Payload map[string]any
|
||||||
|
Retention GameClientBridgeRetention
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotQuery struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
Type string
|
||||||
|
StreamKey string
|
||||||
|
ObservedAfter time.Time
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeProfileDeclaration struct {
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
Available bool
|
||||||
|
Reason string
|
||||||
|
CommandTypes []string
|
||||||
|
SnapshotTypes []string
|
||||||
|
QueryTemplateKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeStatus struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
Available bool
|
||||||
|
Reason string
|
||||||
|
Profiles []GameClientBridgeProfileDeclaration
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeProfileDeclaration(value GameClientBridgeProfileDeclaration) GameClientBridgeProfileDeclaration {
|
||||||
|
value.CommandTypes = CopyStringSlice(value.CommandTypes)
|
||||||
|
value.SnapshotTypes = CopyStringSlice(value.SnapshotTypes)
|
||||||
|
value.QueryTemplateKeys = CopyStringSlice(value.QueryTemplateKeys)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeStatus(value GameClientBridgeStatus) GameClientBridgeStatus {
|
||||||
|
value.Profiles = append([]GameClientBridgeProfileDeclaration(nil), value.Profiles...)
|
||||||
|
for index := range value.Profiles {
|
||||||
|
value.Profiles[index] = CopyGameClientBridgeProfileDeclaration(value.Profiles[index])
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeCommand(value GameClientBridgeCommand) GameClientBridgeCommand {
|
||||||
|
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||||
|
value.Claim = CopyGameClientBridgeClaim(value.Claim)
|
||||||
|
value.Cancellation = CopyGameClientBridgeCancellation(value.Cancellation)
|
||||||
|
value.Result = CopyGameClientBridgeResult(value.Result)
|
||||||
|
value.AuditReferences = CopyStringSlice(value.AuditReferences)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeClaim(value GameClientBridgeClaim) GameClientBridgeClaim { return value }
|
||||||
|
|
||||||
|
func CopyGameClientBridgeCancellation(value GameClientBridgeCancellation) GameClientBridgeCancellation {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeResult(value GameClientBridgeResult) GameClientBridgeResult {
|
||||||
|
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeSnapshot(value GameClientBridgeSnapshot) GameClientBridgeSnapshot {
|
||||||
|
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||||
|
value.Retention = CopyGameClientBridgeRetention(value.Retention)
|
||||||
|
value.AuditReferences = CopyStringSlice(value.AuditReferences)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeSnapshotStream(value GameClientBridgeSnapshotStream) GameClientBridgeSnapshotStream {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeRetention(value GameClientBridgeRetention) GameClientBridgeRetention {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeAuditReference(value GameClientBridgeAuditReference) GameClientBridgeAuditReference {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgePayload(value map[string]any) map[string]any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := make(map[string]any, len(value))
|
||||||
|
for key, item := range value {
|
||||||
|
copy[key] = copyGameClientBridgePayloadValue(item)
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest {
|
||||||
|
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
|
||||||
|
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||||
|
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||||
|
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyGameClientBridgePayloadValue(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return CopyGameClientBridgePayload(typed)
|
||||||
|
case []any:
|
||||||
|
copy := make([]any, len(typed))
|
||||||
|
for index, item := range typed {
|
||||||
|
copy[index] = copyGameClientBridgePayloadValue(item)
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
default:
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
||||||
|
manifest := GameClientBridgeManifest{
|
||||||
|
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}},
|
||||||
|
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||||
|
}
|
||||||
|
manifestCopy := CopyGameClientBridgeManifest(manifest)
|
||||||
|
manifestCopy.QueryTemplates[0].Key = "mutated"
|
||||||
|
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||||
|
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
|
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := GameClientBridgeStatus{Profiles: []GameClientBridgeProfileDeclaration{{QueryTemplateKeys: []string{"player.lookup"}}}}
|
||||||
|
statusCopy := CopyGameClientBridgeStatus(status)
|
||||||
|
statusCopy.Profiles[0].QueryTemplateKeys[0] = "mutated"
|
||||||
|
if status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
|
t.Fatalf("status copy aliases query template keys: source=%#v copy=%#v", status, statusCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -276,6 +276,7 @@ type RunJobReconcileResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
|
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
|
||||||
|
assignment.ExecutionInput.Inputs = CopyStringMap(assignment.ExecutionInput.Inputs)
|
||||||
return assignment
|
return assignment
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +316,9 @@ func CopyRunJobAssignments(assignments []RunJobAssignment) []RunJobAssignment {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]RunJobAssignment, len(assignments))
|
out := make([]RunJobAssignment, len(assignments))
|
||||||
copy(out, assignments)
|
for index, assignment := range assignments {
|
||||||
|
out[index] = CopyRunJobAssignment(assignment)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ type RemoteAdapterRequest struct {
|
|||||||
TimeoutSeconds int
|
TimeoutSeconds int
|
||||||
MaxAttempts int
|
MaxAttempts int
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
|
InputRef string
|
||||||
|
Inputs map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoteAdapterResult struct {
|
type RemoteAdapterResult struct {
|
||||||
@@ -181,5 +183,8 @@ func CopyRemoteAdapterDeclarations(declarations []RemoteAdapterDeclaration) []Re
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest { return request }
|
func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest {
|
||||||
|
request.Inputs = CopyStringMap(request.Inputs)
|
||||||
|
return request
|
||||||
|
}
|
||||||
func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result }
|
func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result }
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type CapacityAdmissionState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CapacityAdmissionAccepted CapacityAdmissionState = "accepted"
|
||||||
|
CapacityAdmissionDeferred CapacityAdmissionState = "deferred"
|
||||||
|
CapacityAdmissionDenied CapacityAdmissionState = "denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CapacityPressureCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CapacityPressureEndpointOffline CapacityPressureCode = "endpoint.offline"
|
||||||
|
CapacityPressureEndpointStale CapacityPressureCode = "endpoint.stale"
|
||||||
|
CapacityPressureCapabilityGap CapacityPressureCode = "capability.missing"
|
||||||
|
CapacityPressureJobLimit CapacityPressureCode = "job.limit"
|
||||||
|
CapacityPressureQueueLimit CapacityPressureCode = "queue.limit"
|
||||||
|
CapacityPressureBacklog CapacityPressureCode = "spool.backlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CapacityAdmissionRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
RunEndpointID string
|
||||||
|
Capability string
|
||||||
|
TargetKey string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CapacityAdmissionDecision struct {
|
||||||
|
Accepted bool
|
||||||
|
State CapacityAdmissionState
|
||||||
|
Reason string
|
||||||
|
RetryAfterSeconds int
|
||||||
|
ServerInstanceID string
|
||||||
|
RunEndpointID string
|
||||||
|
Capability string
|
||||||
|
TargetKey string
|
||||||
|
MaxJobs int
|
||||||
|
RunningJobs int
|
||||||
|
QueuedJobs int
|
||||||
|
PressureCodes []CapacityPressureCode
|
||||||
|
CheckedAt time.Time
|
||||||
|
AlertID string
|
||||||
|
AuditEventID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EndpointCapacityProjection struct {
|
||||||
|
RunEndpointID string
|
||||||
|
DisplayName string
|
||||||
|
Status RunEndpointStatus
|
||||||
|
Capabilities []string
|
||||||
|
MaxJobs int
|
||||||
|
RunningJobs int
|
||||||
|
QueuedJobs int
|
||||||
|
LogBacklogBatches int
|
||||||
|
ArtifactBacklogChunks int
|
||||||
|
PressureCodes []CapacityPressureCode
|
||||||
|
Summary string
|
||||||
|
LastHeartbeatAt time.Time
|
||||||
|
LastAdmissionDecision CapacityAdmissionState
|
||||||
|
LastAdmissionReason string
|
||||||
|
LastAdmissionCheckedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductionCapacitySummary struct {
|
||||||
|
Endpoints []EndpointCapacityProjection
|
||||||
|
TotalMaxJobs int
|
||||||
|
TotalRunningJobs int
|
||||||
|
TotalQueuedJobs int
|
||||||
|
ActiveAlerts int
|
||||||
|
GeneratedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertSeverity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertSeverityInfo AlertSeverity = "info"
|
||||||
|
AlertSeverityWarning AlertSeverity = "warning"
|
||||||
|
AlertSeverityCritical AlertSeverity = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertStateActive AlertState = "active"
|
||||||
|
AlertStateAcknowledged AlertState = "acknowledged"
|
||||||
|
AlertStateResolved AlertState = "resolved"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertRecord struct {
|
||||||
|
ID string
|
||||||
|
SourceKind string
|
||||||
|
SourceID string
|
||||||
|
RuleKey string
|
||||||
|
Severity AlertSeverity
|
||||||
|
State AlertState
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
OccurrenceCount int
|
||||||
|
Retryable bool
|
||||||
|
RetryAfterSeconds int
|
||||||
|
LastJobID string
|
||||||
|
LastAuditEventID string
|
||||||
|
LastSeenAt time.Time
|
||||||
|
AcknowledgedBy string
|
||||||
|
AcknowledgedAt time.Time
|
||||||
|
ResolvedBy string
|
||||||
|
ResolvedAt time.Time
|
||||||
|
ResolutionNote string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertFilter struct {
|
||||||
|
State AlertState
|
||||||
|
SourceKind string
|
||||||
|
SourceID string
|
||||||
|
Severity AlertSeverity
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertAcknowledgeRequest struct {
|
||||||
|
AlertID string
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertResolveRequest struct {
|
||||||
|
AlertID string
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertRetryRequest struct {
|
||||||
|
AlertID string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertRetryResult struct {
|
||||||
|
Alert AlertRecord
|
||||||
|
Decision CapacityAdmissionDecision
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PluginLifecycleStatePending PluginLifecycleState = "pending"
|
||||||
|
PluginLifecycleStateInstalled PluginLifecycleState = "installed"
|
||||||
|
PluginLifecycleStateEnabled PluginLifecycleState = "enabled"
|
||||||
|
PluginLifecycleStateDisabled PluginLifecycleState = "disabled"
|
||||||
|
PluginLifecycleStateUpgrading PluginLifecycleState = "upgrading"
|
||||||
|
PluginLifecycleStateRollingBack PluginLifecycleState = "rolling_back"
|
||||||
|
PluginLifecycleStateRetired PluginLifecycleState = "retired"
|
||||||
|
PluginLifecycleStateFailed PluginLifecycleState = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PluginLifecycleOperation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PluginLifecycleOperationInstall PluginLifecycleOperation = "install"
|
||||||
|
PluginLifecycleOperationEnable PluginLifecycleOperation = "enable"
|
||||||
|
PluginLifecycleOperationDisable PluginLifecycleOperation = "disable"
|
||||||
|
PluginLifecycleOperationUpgrade PluginLifecycleOperation = "upgrade"
|
||||||
|
PluginLifecycleOperationRollback PluginLifecycleOperation = "rollback"
|
||||||
|
PluginLifecycleOperationRetire PluginLifecycleOperation = "retire"
|
||||||
|
PluginLifecycleOperationDependencyCheck PluginLifecycleOperation = "dependency-check"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PluginLifecycleInstallation struct {
|
||||||
|
ID string
|
||||||
|
PluginID string
|
||||||
|
ServerInstanceID string
|
||||||
|
CurrentVersion string
|
||||||
|
TargetVersion string
|
||||||
|
PreviousVersion string
|
||||||
|
DesiredState PluginLifecycleState
|
||||||
|
CurrentState PluginLifecycleState
|
||||||
|
LastOperation PluginLifecycleOperation
|
||||||
|
Compatibility string
|
||||||
|
DependencyState DependencyState
|
||||||
|
JobID string
|
||||||
|
AlertID string
|
||||||
|
AuditEventID string
|
||||||
|
FailureReason string
|
||||||
|
IdempotencyKey string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleFilter struct {
|
||||||
|
PluginID string
|
||||||
|
ServerInstanceID string
|
||||||
|
CurrentState PluginLifecycleState
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleRequest struct {
|
||||||
|
PluginID string
|
||||||
|
ServerInstanceID string
|
||||||
|
Operation PluginLifecycleOperation
|
||||||
|
TargetVersion string
|
||||||
|
IdempotencyKey string
|
||||||
|
Confirmed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleResult struct {
|
||||||
|
Installation PluginLifecycleInstallation
|
||||||
|
Job Job
|
||||||
|
Decision CapacityAdmissionDecision
|
||||||
|
Alert *AlertRecord
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AIConfigDiffStatePending AIConfigDiffState = "pending"
|
||||||
|
AIConfigDiffStateApproved AIConfigDiffState = "approved"
|
||||||
|
AIConfigDiffStateCancelled AIConfigDiffState = "cancelled"
|
||||||
|
AIConfigDiffStateExpired AIConfigDiffState = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AIConfigDiffPreview struct {
|
||||||
|
ID string
|
||||||
|
RequestID string
|
||||||
|
CreatedBy string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProviderID string
|
||||||
|
Model string
|
||||||
|
Key string
|
||||||
|
ConfigVersion int
|
||||||
|
CurrentConfigChecksum string
|
||||||
|
ProposedConfig string
|
||||||
|
DiffSummary string
|
||||||
|
State AIConfigDiffState
|
||||||
|
ExpiresAt time.Time
|
||||||
|
ApprovedBy string
|
||||||
|
ApprovedAt time.Time
|
||||||
|
ApprovalIdempotencyKey string
|
||||||
|
CancelledBy string
|
||||||
|
CancelledAt time.Time
|
||||||
|
JobID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
State AIConfigDiffState
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffApprovalRequest struct {
|
||||||
|
DiffID string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffApprovalResult struct {
|
||||||
|
Preview AIConfigDiffPreview
|
||||||
|
Dispatch ServerConfigWriteDispatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyCapacityAdmissionDecision(decision CapacityAdmissionDecision) CapacityAdmissionDecision {
|
||||||
|
decision.PressureCodes = CopyCapacityPressureCodes(decision.PressureCodes)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyEndpointCapacityProjection(projection EndpointCapacityProjection) EndpointCapacityProjection {
|
||||||
|
projection.Capabilities = CopyStringSlice(projection.Capabilities)
|
||||||
|
projection.PressureCodes = CopyCapacityPressureCodes(projection.PressureCodes)
|
||||||
|
return projection
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyProductionCapacitySummary(summary ProductionCapacitySummary) ProductionCapacitySummary {
|
||||||
|
if summary.Endpoints != nil {
|
||||||
|
summary.Endpoints = append([]EndpointCapacityProjection(nil), summary.Endpoints...)
|
||||||
|
for i := range summary.Endpoints {
|
||||||
|
summary.Endpoints[i] = CopyEndpointCapacityProjection(summary.Endpoints[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyCapacityPressureCodes(codes []CapacityPressureCode) []CapacityPressureCode {
|
||||||
|
if codes == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]CapacityPressureCode, len(codes))
|
||||||
|
copy(out, codes)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyAlertRecord(alert AlertRecord) AlertRecord { return alert }
|
||||||
|
|
||||||
|
func CopyAlertRecords(alerts []AlertRecord) []AlertRecord {
|
||||||
|
if alerts == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]AlertRecord, len(alerts))
|
||||||
|
copy(out, alerts)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyAlertRetryResult(result AlertRetryResult) AlertRetryResult {
|
||||||
|
result.Alert = CopyAlertRecord(result.Alert)
|
||||||
|
result.Decision = CopyCapacityAdmissionDecision(result.Decision)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyPluginLifecycleInstallation(installation PluginLifecycleInstallation) PluginLifecycleInstallation {
|
||||||
|
return installation
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyPluginLifecycleInstallations(installations []PluginLifecycleInstallation) []PluginLifecycleInstallation {
|
||||||
|
if installations == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]PluginLifecycleInstallation, len(installations))
|
||||||
|
copy(out, installations)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyPluginLifecycleResult(result PluginLifecycleResult) PluginLifecycleResult {
|
||||||
|
result.Installation = CopyPluginLifecycleInstallation(result.Installation)
|
||||||
|
result.Job = CopyJob(result.Job)
|
||||||
|
result.Decision = CopyCapacityAdmissionDecision(result.Decision)
|
||||||
|
if result.Alert != nil {
|
||||||
|
alert := CopyAlertRecord(*result.Alert)
|
||||||
|
result.Alert = &alert
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyAIConfigDiffPreview(preview AIConfigDiffPreview) AIConfigDiffPreview {
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyAIConfigDiffPreviews(previews []AIConfigDiffPreview) []AIConfigDiffPreview {
|
||||||
|
if previews == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]AIConfigDiffPreview, len(previews))
|
||||||
|
copy(out, previews)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyAIConfigDiffApprovalResult(result AIConfigDiffApprovalResult) AIConfigDiffApprovalResult {
|
||||||
|
result.Preview = CopyAIConfigDiffPreview(result.Preview)
|
||||||
|
result.Dispatch = CopyServerConfigWriteDispatch(result.Dispatch)
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -329,6 +329,14 @@ type GamePluginManifestServer struct {
|
|||||||
|
|
||||||
type GamePluginManifestAI struct {
|
type GamePluginManifestAI struct {
|
||||||
Purposes []string
|
Purposes []string
|
||||||
|
Mediation string
|
||||||
|
ConfigWritePolicy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GamePluginProductionLifecycle struct {
|
||||||
|
Operations []string
|
||||||
|
DependencyPolicy string
|
||||||
|
ApprovalRequired []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginRemoteAccess struct {
|
type GamePluginRemoteAccess struct {
|
||||||
@@ -398,6 +406,26 @@ type RuntimeLogSource struct {
|
|||||||
RetentionDays int
|
RetentionDays int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeLogEventSeverity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RuntimeLogEventSeverityInfo RuntimeLogEventSeverity = "info"
|
||||||
|
RuntimeLogEventSeverityNotice RuntimeLogEventSeverity = "notice"
|
||||||
|
RuntimeLogEventSeverityWarning RuntimeLogEventSeverity = "warning"
|
||||||
|
RuntimeLogEventSeverityCritical RuntimeLogEventSeverity = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RuntimeLogEvent struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
SourceKey string
|
||||||
|
EventType string
|
||||||
|
Permission string
|
||||||
|
SchemaRef string
|
||||||
|
RetentionDays int
|
||||||
|
Severity RuntimeLogEventSeverity
|
||||||
|
}
|
||||||
|
|
||||||
type RuntimeTransportProfile struct {
|
type RuntimeTransportProfile struct {
|
||||||
Key string
|
Key string
|
||||||
Kind string
|
Kind string
|
||||||
@@ -439,6 +467,7 @@ type GamePluginRuntimeProfiles struct {
|
|||||||
DependencyProbes []RuntimeDependencyProbe
|
DependencyProbes []RuntimeDependencyProbe
|
||||||
InstallPlans []RuntimeInstallPlan
|
InstallPlans []RuntimeInstallPlan
|
||||||
LogSources []RuntimeLogSource
|
LogSources []RuntimeLogSource
|
||||||
|
LogEvents []RuntimeLogEvent
|
||||||
TransportProfiles []RuntimeTransportProfile
|
TransportProfiles []RuntimeTransportProfile
|
||||||
ClientManagers []RuntimeClientManagerProfile
|
ClientManagers []RuntimeClientManagerProfile
|
||||||
}
|
}
|
||||||
@@ -457,8 +486,10 @@ type GamePluginManifest struct {
|
|||||||
Actions PluginLifecycleActions
|
Actions PluginLifecycleActions
|
||||||
Pages []GamePluginPage
|
Pages []GamePluginPage
|
||||||
AI GamePluginManifestAI
|
AI GamePluginManifestAI
|
||||||
|
ProductionLifecycle GamePluginProductionLifecycle
|
||||||
RemoteAccess GamePluginRemoteAccess
|
RemoteAccess GamePluginRemoteAccess
|
||||||
RuntimeProfiles GamePluginRuntimeProfiles
|
RuntimeProfiles GamePluginRuntimeProfiles
|
||||||
|
GameClientBridge GameClientBridgeManifest
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginManifestRegistration struct {
|
type GamePluginManifestRegistration struct {
|
||||||
@@ -484,8 +515,10 @@ type GamePlugin struct {
|
|||||||
Pages []GamePluginPage
|
Pages []GamePluginPage
|
||||||
Tags []string
|
Tags []string
|
||||||
AIPurposes []string
|
AIPurposes []string
|
||||||
|
ProductionLifecycle GamePluginProductionLifecycle
|
||||||
RemoteAccess GamePluginRemoteAccess
|
RemoteAccess GamePluginRemoteAccess
|
||||||
RuntimeProfiles GamePluginRuntimeProfiles
|
RuntimeProfiles GamePluginRuntimeProfiles
|
||||||
|
GameClientBridge GameClientBridgeManifest
|
||||||
ValidationViolations []string
|
ValidationViolations []string
|
||||||
Status GamePluginStatus
|
Status GamePluginStatus
|
||||||
}
|
}
|
||||||
@@ -508,8 +541,10 @@ type PluginMarketplacePlugin struct {
|
|||||||
Pages []GamePluginPage
|
Pages []GamePluginPage
|
||||||
Tags []string
|
Tags []string
|
||||||
AIPurposes []string
|
AIPurposes []string
|
||||||
|
ProductionLifecycle GamePluginProductionLifecycle
|
||||||
RemoteAccess GamePluginRemoteAccess
|
RemoteAccess GamePluginRemoteAccess
|
||||||
RuntimeProfiles GamePluginRuntimeProfiles
|
RuntimeProfiles GamePluginRuntimeProfiles
|
||||||
|
GameClientBridge GameClientBridgeManifest
|
||||||
ValidationViolations []string
|
ValidationViolations []string
|
||||||
Status GamePluginStatus
|
Status GamePluginStatus
|
||||||
Source string
|
Source string
|
||||||
@@ -528,6 +563,7 @@ const (
|
|||||||
PluginBridgeActionDependenciesRequest PluginBridgeAction = "dependencies.request"
|
PluginBridgeActionDependenciesRequest PluginBridgeAction = "dependencies.request"
|
||||||
PluginBridgeActionLogsBackfillRequest PluginBridgeAction = "logs.backfill.request"
|
PluginBridgeActionLogsBackfillRequest PluginBridgeAction = "logs.backfill.request"
|
||||||
PluginBridgeActionClientManager PluginBridgeAction = "client-manager.request"
|
PluginBridgeActionClientManager PluginBridgeAction = "client-manager.request"
|
||||||
|
PluginBridgeActionPluginLifecycle PluginBridgeAction = "plugin-lifecycle.request"
|
||||||
PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke"
|
PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -711,6 +747,9 @@ type RunCapacity struct {
|
|||||||
MaxJobs int
|
MaxJobs int
|
||||||
RunningJobs int
|
RunningJobs int
|
||||||
QueuedJobs int
|
QueuedJobs int
|
||||||
|
LogBacklogBatches int
|
||||||
|
ArtifactBacklogChunks int
|
||||||
|
PressureCodes []string
|
||||||
Summary string
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,6 +808,10 @@ type JobExecutionInput struct {
|
|||||||
RemoteAdapterKey string
|
RemoteAdapterKey string
|
||||||
RemoteAdapterKind string
|
RemoteAdapterKind string
|
||||||
TimeoutSeconds int
|
TimeoutSeconds int
|
||||||
|
PluginID string
|
||||||
|
LifecycleOperation string
|
||||||
|
TargetVersion string
|
||||||
|
Inputs map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobExecutionResult struct {
|
type JobExecutionResult struct {
|
||||||
@@ -1284,8 +1327,10 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
|
|||||||
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
||||||
plugin.Tags = CopyStringSlice(plugin.Tags)
|
plugin.Tags = CopyStringSlice(plugin.Tags)
|
||||||
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
|
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
|
||||||
|
plugin.ProductionLifecycle = CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle)
|
||||||
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||||
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
||||||
|
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
|
||||||
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
||||||
return plugin
|
return plugin
|
||||||
}
|
}
|
||||||
@@ -1298,8 +1343,10 @@ func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketpla
|
|||||||
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
||||||
plugin.Tags = CopyStringSlice(plugin.Tags)
|
plugin.Tags = CopyStringSlice(plugin.Tags)
|
||||||
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
|
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
|
||||||
|
plugin.ProductionLifecycle = CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle)
|
||||||
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||||
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
||||||
|
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
|
||||||
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
||||||
return plugin
|
return plugin
|
||||||
}
|
}
|
||||||
@@ -1328,11 +1375,19 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
|
|||||||
manifest.Permissions = CopyStringSlice(manifest.Permissions)
|
manifest.Permissions = CopyStringSlice(manifest.Permissions)
|
||||||
manifest.Pages = CopyGamePluginPageSlice(manifest.Pages)
|
manifest.Pages = CopyGamePluginPageSlice(manifest.Pages)
|
||||||
manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes)
|
manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes)
|
||||||
|
manifest.ProductionLifecycle = CopyGamePluginProductionLifecycle(manifest.ProductionLifecycle)
|
||||||
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
|
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
|
||||||
manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles)
|
manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles)
|
||||||
|
manifest.GameClientBridge = CopyGameClientBridgeManifest(manifest.GameClientBridge)
|
||||||
return manifest
|
return manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CopyGamePluginProductionLifecycle(lifecycle GamePluginProductionLifecycle) GamePluginProductionLifecycle {
|
||||||
|
lifecycle.Operations = CopyStringSlice(lifecycle.Operations)
|
||||||
|
lifecycle.ApprovalRequired = CopyStringSlice(lifecycle.ApprovalRequired)
|
||||||
|
return lifecycle
|
||||||
|
}
|
||||||
|
|
||||||
func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePluginRuntimeProfiles {
|
func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePluginRuntimeProfiles {
|
||||||
profiles.Discovery = append([]RuntimeDiscoveryProbe(nil), profiles.Discovery...)
|
profiles.Discovery = append([]RuntimeDiscoveryProbe(nil), profiles.Discovery...)
|
||||||
for i := range profiles.Discovery {
|
for i := range profiles.Discovery {
|
||||||
@@ -1354,6 +1409,7 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
|
|||||||
profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...)
|
profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...)
|
||||||
}
|
}
|
||||||
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
|
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
|
||||||
|
profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...)
|
||||||
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
|
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
|
||||||
for i := range profiles.TransportProfiles {
|
for i := range profiles.TransportProfiles {
|
||||||
profiles.TransportProfiles[i].Capabilities = CopyStringSlice(profiles.TransportProfiles[i].Capabilities)
|
profiles.TransportProfiles[i].Capabilities = CopyStringSlice(profiles.TransportProfiles[i].Capabilities)
|
||||||
@@ -1465,10 +1521,12 @@ func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOpe
|
|||||||
|
|
||||||
func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
|
func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
|
||||||
endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities)
|
endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities)
|
||||||
|
endpoint.Capacity.PressureCodes = CopyStringSlice(endpoint.Capacity.PressureCodes)
|
||||||
return endpoint
|
return endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyJob(job Job) Job {
|
func CopyJob(job Job) Job {
|
||||||
|
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,3 +192,6 @@ Failed or cancelled lifecycle jobs project the server instance to `failed`. Acti
|
|||||||
- `result`: `success`, `denied`, `failed`, or `queued`.
|
- `result`: `success`, `denied`, `failed`, or `queued`.
|
||||||
- `summary`: bounded redacted summary.
|
- `summary`: bounded redacted summary.
|
||||||
- `createdAt`: event time.
|
- `createdAt`: event time.
|
||||||
|
# Client Manager lifecycle aggregates
|
||||||
|
|
||||||
|
`ClientManagerInstallation` owns desired/active/previous artifact and version metadata, target and key/deployment generations, the current job, logical health/last-seen projection, retry/fencing flags, and uninstall history. Valid statuses are `requested`, `building`, `available`, `deploying`, `installed`, `registering`, `online`, `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `failed`, and `uninstalled`. `ClientManagerSession` is a separate short-lived component identity bound to installation, endpoint, artifact, key generation, and deployment generation; it is not a Run session or lease.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCopyGamePluginRuntimeProfilesCopiesLogEvents(t *testing.T) {
|
||||||
|
profiles := GamePluginRuntimeProfiles{
|
||||||
|
LogEvents: []RuntimeLogEvent{{
|
||||||
|
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||||
|
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
copied := CopyGamePluginRuntimeProfiles(profiles)
|
||||||
|
copied.LogEvents[0].Title = "Mutated"
|
||||||
|
|
||||||
|
if profiles.LogEvents[0].Title != "Chat message" {
|
||||||
|
t.Fatalf("expected log event declarations to be copied, got %+v", profiles.LogEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ type AIConfigRecommendationResponse struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||||
DiffSummary string `json:"diffSummary"`
|
DiffSummary string `json:"diffSummary"`
|
||||||
|
DiffID string `json:"diffId,omitempty"`
|
||||||
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIInvocationSafeErrorResponse struct {
|
type AIInvocationSafeErrorResponse struct {
|
||||||
@@ -82,6 +84,8 @@ func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationRe
|
|||||||
Key: response.ConfigRecommendation.Key,
|
Key: response.ConfigRecommendation.Key,
|
||||||
SuggestedConfig: response.ConfigRecommendation.SuggestedConfig,
|
SuggestedConfig: response.ConfigRecommendation.SuggestedConfig,
|
||||||
DiffSummary: response.ConfigRecommendation.DiffSummary,
|
DiffSummary: response.ConfigRecommendation.DiffSummary,
|
||||||
|
DiffID: response.ConfigRecommendation.DiffID,
|
||||||
|
ExpiresAt: response.ConfigRecommendation.ExpiresAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var safeError *AIInvocationSafeErrorResponse
|
var safeError *AIInvocationSafeErrorResponse
|
||||||
|
|||||||
@@ -141,6 +141,37 @@ type ClientManagerLifecycleInputResponse struct {
|
|||||||
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
|
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
|
||||||
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
|
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
|
||||||
IdempotencyKey string `json:"idempotencyKey"`
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
CompanionConfig *ClientManagerCompanionConfigInputResponse `json:"companionConfig,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerCompanionConfigInputResponse struct {
|
||||||
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
|
ConfigTemplateKey string `json:"configTemplateKey"`
|
||||||
|
ConfigTemplateRef string `json:"configTemplateRef"`
|
||||||
|
ConfigOutputRef string `json:"configOutputRef"`
|
||||||
|
ConfigSchemaRef string `json:"configSchemaRef"`
|
||||||
|
ConfigFormat string `json:"configFormat"`
|
||||||
|
PlatformBaseURLSource string `json:"platformBaseUrlSource"`
|
||||||
|
InstallationID string `json:"installationId"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
ArtifactID string `json:"artifactId"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SourceRevision string `json:"sourceRevision"`
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
KeyGeneration int `json:"keyGeneration"`
|
||||||
|
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
RegistrationProof string `json:"registrationProof"`
|
||||||
|
ProofMaterialSource string `json:"proofMaterialSource"`
|
||||||
|
ProofMaterialEnv string `json:"proofMaterialEnv"`
|
||||||
|
SessionMode string `json:"sessionMode"`
|
||||||
|
TLSPolicy string `json:"tlsPolicy"`
|
||||||
|
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
|
||||||
|
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"`
|
||||||
|
RequestTimeoutSeconds int `json:"requestTimeoutSeconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientManagerRegisterRequest struct {
|
type ClientManagerRegisterRequest struct {
|
||||||
@@ -219,7 +250,17 @@ func (request ClientManagerLifecycleInputRequest) ToDomain() domain.ClientManage
|
|||||||
|
|
||||||
func ClientManagerLifecycleInputFromDomain(value domain.ClientManagerLifecycleInput) ClientManagerLifecycleInputResponse {
|
func ClientManagerLifecycleInputFromDomain(value domain.ClientManagerLifecycleInput) ClientManagerLifecycleInputResponse {
|
||||||
value = domain.CopyClientManagerLifecycleInput(value)
|
value = domain.CopyClientManagerLifecycleInput(value)
|
||||||
return ClientManagerLifecycleInputResponse{InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, ProfileKey: value.ProfileKey, Operation: string(value.Operation), ArtifactID: value.ArtifactID, Checksum: value.Checksum, TargetOS: value.TargetOS, TargetArch: value.TargetArch, Version: value.Version, SourceRevision: value.SourceRevision, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, ExecutableRef: value.ExecutableRef, Arguments: value.Arguments, AutoStart: value.AutoStart, StartupTimeoutSeconds: value.StartupTimeoutSeconds, StopTimeoutSeconds: value.StopTimeoutSeconds, HealthConfirmationSeconds: value.HealthConfirmationSeconds, IdempotencyKey: value.IdempotencyKey}
|
response := ClientManagerLifecycleInputResponse{InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, ProfileKey: value.ProfileKey, Operation: string(value.Operation), ArtifactID: value.ArtifactID, Checksum: value.Checksum, TargetOS: value.TargetOS, TargetArch: value.TargetArch, Version: value.Version, SourceRevision: value.SourceRevision, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, ExecutableRef: value.ExecutableRef, Arguments: value.Arguments, AutoStart: value.AutoStart, StartupTimeoutSeconds: value.StartupTimeoutSeconds, StopTimeoutSeconds: value.StopTimeoutSeconds, HealthConfirmationSeconds: value.HealthConfirmationSeconds, IdempotencyKey: value.IdempotencyKey}
|
||||||
|
if value.CompanionConfig != nil {
|
||||||
|
companion := ClientManagerCompanionConfigInputFromDomain(*value.CompanionConfig)
|
||||||
|
response.CompanionConfig = &companion
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClientManagerCompanionConfigInputFromDomain(value domain.ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInputResponse {
|
||||||
|
value = domain.CopyClientManagerCompanionConfigInput(value)
|
||||||
|
return ClientManagerCompanionConfigInputResponse{SchemaVersion: value.SchemaVersion, ConfigTemplateKey: value.ConfigTemplateKey, ConfigTemplateRef: value.ConfigTemplateRef, ConfigOutputRef: value.ConfigOutputRef, ConfigSchemaRef: value.ConfigSchemaRef, ConfigFormat: value.ConfigFormat, PlatformBaseURLSource: value.PlatformBaseURLSource, InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, ProfileKey: value.ProfileKey, ArtifactID: value.ArtifactID, Version: value.Version, SourceRevision: value.SourceRevision, TargetOS: value.TargetOS, TargetArch: value.TargetArch, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, Capabilities: value.Capabilities, RegistrationProof: value.RegistrationProof, ProofMaterialSource: value.ProofMaterialSource, ProofMaterialEnv: value.ProofMaterialEnv, SessionMode: value.SessionMode, TLSPolicy: value.TLSPolicy, HeartbeatIntervalSeconds: value.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.RequestTimeoutSeconds}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (request ClientManagerRegisterRequest) ToDomain() domain.ClientManagerRegisterRequest {
|
func (request ClientManagerRegisterRequest) ToDomain() domain.ClientManagerRegisterRequest {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientManagerLifecycleInputProjectsRunOnlyCompanionConfigWithoutRawMaterial(t *testing.T) {
|
||||||
|
response := ClientManagerLifecycleInputFromDomain(domain.ClientManagerLifecycleInput{
|
||||||
|
InstallationID: "installation-1",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
Operation: domain.ClientManagerOperationDeploy,
|
||||||
|
DeploymentGeneration: 2,
|
||||||
|
CompanionConfig: &domain.ClientManagerCompanionConfigInput{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
ConfigTemplateKey: "client-config",
|
||||||
|
ConfigTemplateRef: "config.yaml.example",
|
||||||
|
ConfigOutputRef: "config.yaml",
|
||||||
|
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||||
|
ConfigFormat: "yaml",
|
||||||
|
PlatformBaseURLSource: "run-control",
|
||||||
|
InstallationID: "installation-1",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
PluginID: "game.scum",
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
ArtifactID: "artifact-1",
|
||||||
|
Version: "1.0.0",
|
||||||
|
SourceRevision: "revision-1",
|
||||||
|
TargetOS: "windows",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
KeyGeneration: 3,
|
||||||
|
DeploymentGeneration: 2,
|
||||||
|
Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"},
|
||||||
|
RegistrationProof: "hmac-sha256",
|
||||||
|
ProofMaterialSource: "component-package",
|
||||||
|
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||||
|
SessionMode: "component-session",
|
||||||
|
TLSPolicy: "verify-system-roots",
|
||||||
|
HeartbeatIntervalSeconds: 30,
|
||||||
|
CommandPollIntervalSeconds: 5,
|
||||||
|
RequestTimeoutSeconds: 15,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if response.CompanionConfig == nil || response.CompanionConfig.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" {
|
||||||
|
t.Fatalf("expected run-only companion projection, got %+v", response.CompanionConfig)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal lifecycle input: %v", err)
|
||||||
|
}
|
||||||
|
serialized := string(encoded)
|
||||||
|
for _, forbidden := range []string{`"proofMaterial":`, `"authKey":`, `"componentKey":`, `"sessionToken":`, `"secretRef":`, "/Users/", "run.socket"} {
|
||||||
|
if strings.Contains(serialized, forbidden) {
|
||||||
|
t.Fatalf("run lifecycle input leaked %q: %s", forbidden, serialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browserProjection, err := json.Marshal(ClientManagerLifecycleViewFromDomain(domain.ClientManagerLifecycleView{Installation: domain.ClientManagerInstallation{ID: "installation-1"}}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal browser lifecycle projection: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(browserProjection), "companionConfig") || strings.Contains(string(browserProjection), "proofMaterialEnv") {
|
||||||
|
t.Fatalf("browser lifecycle projection exposed run-only config: %s", browserProjection)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameClientBridgeQueueRequest struct {
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
CommandType string `json:"commandType"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeClaimRequest struct {
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeAckRequest struct {
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
|
FencingToken uint64 `json:"fencingToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeResultRequest struct {
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
|
FencingToken uint64 `json:"fencingToken"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Payload map[string]any `json:"payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotIngestRequest struct {
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
|
StreamKey string `json:"streamKey"`
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
ObservedAt time.Time `json:"observedAt"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
KeepForSeconds int `json:"keepForSeconds"`
|
||||||
|
MaxRecords int `json:"maxRecords,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCancelRequest struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotQuery struct {
|
||||||
|
ProfileKey string `json:"profileKey,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
StreamKey string `json:"streamKey,omitempty"`
|
||||||
|
ObservedAfter time.Time `json:"observedAfter,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameClientBridgeCommandResultResponse is the browser-safe projection of a
|
||||||
|
// terminal command result. Result payloads pass the same bounded JSON
|
||||||
|
// validation as command inputs before they can be persisted.
|
||||||
|
type GameClientBridgeCommandResultResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Payload map[string]any `json:"payload,omitempty"`
|
||||||
|
CompletedAt time.Time `json:"completedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCommandCancellationResponse struct {
|
||||||
|
RequestedBy string `json:"requestedBy,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
CancelledAt time.Time `json:"cancelledAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCommandResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
CommandType string `json:"commandType"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
State string `json:"state"`
|
||||||
|
ApprovalState string `json:"approvalState"`
|
||||||
|
RequesterID string `json:"requesterId,omitempty"`
|
||||||
|
ResultSummary string `json:"resultSummary,omitempty"`
|
||||||
|
Result *GameClientBridgeCommandResultResponse `json:"result,omitempty"`
|
||||||
|
Cancellation *GameClientBridgeCommandCancellationResponse `json:"cancellation,omitempty"`
|
||||||
|
AuditReferences []string `json:"auditReferences,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeRetentionResponse struct {
|
||||||
|
KeepForSeconds int `json:"keepForSeconds"`
|
||||||
|
MaxRecords int `json:"maxRecords,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
|
StreamKey string `json:"streamKey"`
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
ObservedAt time.Time `json:"observedAt"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
Retention GameClientBridgeRetentionResponse `json:"retention"`
|
||||||
|
AuditReferences []string `json:"auditReferences,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCommandListResponse struct {
|
||||||
|
Items []GameClientBridgeCommandResponse `json:"items"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotListResponse struct {
|
||||||
|
Items []GameClientBridgeSnapshotResponse `json:"items"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeClaimedCommandResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
CommandType string `json:"commandType"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
FencingToken uint64 `json:"fencingToken"`
|
||||||
|
ClaimedAt time.Time `json:"claimedAt"`
|
||||||
|
LeaseExpiresAt time.Time `json:"leaseExpiresAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeClaimResponse struct {
|
||||||
|
Items []GameClientBridgeClaimedCommandResponse `json:"items"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameClientBridgeClaimBatchResponse is kept as a descriptive alias for API
|
||||||
|
// handlers that name the operation after its bounded batch semantics.
|
||||||
|
type GameClientBridgeClaimBatchResponse = GameClientBridgeClaimResponse
|
||||||
|
|
||||||
|
type GameClientBridgeAckResponse struct {
|
||||||
|
CommandID string `json:"commandId"`
|
||||||
|
State string `json:"state"`
|
||||||
|
FencingToken uint64 `json:"fencingToken"`
|
||||||
|
AcknowledgedAt time.Time `json:"acknowledgedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeResultResponse struct {
|
||||||
|
CommandID string `json:"commandId"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Result GameClientBridgeCommandResultResponse `json:"result"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
CompletedAt time.Time `json:"completedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCancelResponse struct {
|
||||||
|
CommandID string `json:"commandId"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Cancellation GameClientBridgeCommandCancellationResponse `json:"cancellation"`
|
||||||
|
AuditReferences []string `json:"auditReferences,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotIngestResponse struct {
|
||||||
|
SnapshotID string `json:"snapshotId"`
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
|
StreamKey string `json:"streamKey"`
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
AcceptedAt time.Time `json:"acceptedAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeAuditReferenceResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CommandID string `json:"commandId,omitempty"`
|
||||||
|
SnapshotID string `json:"snapshotId,omitempty"`
|
||||||
|
AuditEventID string `json:"auditEventId"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeAuditReferenceListResponse struct {
|
||||||
|
Items []GameClientBridgeAuditReferenceResponse `json:"items"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeProfileDeclarationResponse struct {
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
CommandTypes []string `json:"commandTypes"`
|
||||||
|
SnapshotTypes []string `json:"snapshotTypes"`
|
||||||
|
QueryTemplateKeys []string `json:"queryTemplateKeys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeStatusResponse struct {
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Profiles []GameClientBridgeProfileDeclarationResponse `json:"profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request GameClientBridgeQueueRequest) ToDomain(serverID, pluginID string) domain.GameClientBridgeQueueRequest {
|
||||||
|
return domain.GameClientBridgeQueueRequest{ServerInstanceID: serverID, PluginID: pluginID, ProfileKey: request.ProfileKey, CommandType: request.CommandType, Payload: domain.CopyGameClientBridgePayload(request.Payload), IdempotencyKey: request.IdempotencyKey, Priority: request.Priority, ExpiresAt: request.ExpiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request GameClientBridgeClaimRequest) ToDomain() domain.GameClientBridgeClaimRequest {
|
||||||
|
return domain.GameClientBridgeClaimRequest{SessionToken: request.SessionToken, Limit: request.Limit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request GameClientBridgeAckRequest) ToDomain(commandID string) domain.GameClientBridgeAckRequest {
|
||||||
|
return domain.GameClientBridgeAckRequest{SessionToken: request.SessionToken, CommandID: commandID, FencingToken: request.FencingToken}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request GameClientBridgeResultRequest) ToDomain(commandID string) domain.GameClientBridgeResultRequest {
|
||||||
|
return domain.GameClientBridgeResultRequest{SessionToken: request.SessionToken, CommandID: commandID, FencingToken: request.FencingToken, Status: domain.GameClientBridgeResultStatus(request.Status), Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request GameClientBridgeCancelRequest) ToDomain(commandID string) domain.GameClientBridgeCancelRequest {
|
||||||
|
return domain.GameClientBridgeCancelRequest{CommandID: commandID, Reason: request.Reason}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request GameClientBridgeSnapshotIngestRequest) ToDomain() domain.GameClientBridgeSnapshotIngestRequest {
|
||||||
|
return domain.GameClientBridgeSnapshotIngestRequest{SessionToken: request.SessionToken, Type: request.Type, SchemaVersion: request.SchemaVersion, StreamKey: request.StreamKey, Sequence: request.Sequence, ObservedAt: request.ObservedAt, Payload: domain.CopyGameClientBridgePayload(request.Payload), Retention: domain.GameClientBridgeRetention{KeepForSeconds: request.KeepForSeconds, MaxRecords: request.MaxRecords}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (query GameClientBridgeSnapshotQuery) ToDomain(serverID, pluginID string) domain.GameClientBridgeSnapshotQuery {
|
||||||
|
return domain.GameClientBridgeSnapshotQuery{ServerInstanceID: serverID, PluginID: pluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: query.Limit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeCommandFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeCommandResponse {
|
||||||
|
value = domain.CopyGameClientBridgeCommand(value)
|
||||||
|
response := GameClientBridgeCommandResponse{
|
||||||
|
ID: value.ID,
|
||||||
|
ServerInstanceID: value.ServerInstanceID,
|
||||||
|
PluginID: value.PluginID,
|
||||||
|
ProfileKey: value.ProfileKey,
|
||||||
|
CommandType: value.CommandType,
|
||||||
|
Priority: value.Priority,
|
||||||
|
State: string(value.State),
|
||||||
|
ApprovalState: string(value.ApprovalState),
|
||||||
|
RequesterID: value.RequesterID,
|
||||||
|
ResultSummary: value.Result.Summary,
|
||||||
|
AuditReferences: copyStrings(value.AuditReferences),
|
||||||
|
ExpiresAt: value.ExpiresAt,
|
||||||
|
CreatedAt: value.CreatedAt,
|
||||||
|
UpdatedAt: value.UpdatedAt,
|
||||||
|
CompletedAt: value.CompletedAt,
|
||||||
|
}
|
||||||
|
if hasGameClientBridgeResult(value.Result) {
|
||||||
|
result := gameClientBridgeCommandResultFromDomain(value.Result)
|
||||||
|
response.Result = &result
|
||||||
|
}
|
||||||
|
if hasGameClientBridgeCancellation(value.Cancellation) {
|
||||||
|
cancellation := gameClientBridgeCommandCancellationFromDomain(value.Cancellation)
|
||||||
|
response.Cancellation = &cancellation
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeSnapshotFromDomain(value domain.GameClientBridgeSnapshot) GameClientBridgeSnapshotResponse {
|
||||||
|
value = domain.CopyGameClientBridgeSnapshot(value)
|
||||||
|
return GameClientBridgeSnapshotResponse{
|
||||||
|
ID: value.ID,
|
||||||
|
ServerInstanceID: value.ServerInstanceID,
|
||||||
|
PluginID: value.PluginID,
|
||||||
|
ProfileKey: value.ProfileKey,
|
||||||
|
Type: value.Type,
|
||||||
|
SchemaVersion: value.SchemaVersion,
|
||||||
|
StreamKey: value.StreamKey,
|
||||||
|
Sequence: value.Sequence,
|
||||||
|
ObservedAt: value.ObservedAt,
|
||||||
|
Payload: value.Payload,
|
||||||
|
Retention: gameClientBridgeRetentionFromDomain(value.Retention),
|
||||||
|
AuditReferences: copyStrings(value.AuditReferences),
|
||||||
|
CreatedAt: value.CreatedAt,
|
||||||
|
ExpiresAt: value.ExpiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeClaimedCommandFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeClaimedCommandResponse {
|
||||||
|
value = domain.CopyGameClientBridgeCommand(value)
|
||||||
|
return GameClientBridgeClaimedCommandResponse{ID: value.ID, ProfileKey: value.ProfileKey, CommandType: value.CommandType, Payload: value.Payload, Priority: value.Priority, FencingToken: value.Claim.FencingToken, ClaimedAt: value.Claim.ClaimedAt, LeaseExpiresAt: value.Claim.LeaseExpiresAt, ExpiresAt: value.ExpiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeCommandsFromDomain(values []domain.GameClientBridgeCommand) GameClientBridgeCommandListResponse {
|
||||||
|
items := make([]GameClientBridgeCommandResponse, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
items[index] = GameClientBridgeCommandFromDomain(value)
|
||||||
|
}
|
||||||
|
return GameClientBridgeCommandListResponse{Items: items, Count: len(items)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeSnapshotsFromDomain(values []domain.GameClientBridgeSnapshot) GameClientBridgeSnapshotListResponse {
|
||||||
|
items := make([]GameClientBridgeSnapshotResponse, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
items[index] = GameClientBridgeSnapshotFromDomain(value)
|
||||||
|
}
|
||||||
|
return GameClientBridgeSnapshotListResponse{Items: items, Count: len(items)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeClaimResponseFromDomain(values []domain.GameClientBridgeCommand) GameClientBridgeClaimResponse {
|
||||||
|
items := make([]GameClientBridgeClaimedCommandResponse, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
items[index] = GameClientBridgeClaimedCommandFromDomain(value)
|
||||||
|
}
|
||||||
|
return GameClientBridgeClaimResponse{Items: items, Count: len(items)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeClaimBatchFromDomain(values []domain.GameClientBridgeCommand) GameClientBridgeClaimBatchResponse {
|
||||||
|
return GameClientBridgeClaimResponseFromDomain(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeAckFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeAckResponse {
|
||||||
|
return GameClientBridgeAckResponse{CommandID: value.ID, State: string(value.State), FencingToken: value.Claim.FencingToken, AcknowledgedAt: value.Claim.AcknowledgedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeResultFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeResultResponse {
|
||||||
|
value = domain.CopyGameClientBridgeCommand(value)
|
||||||
|
return GameClientBridgeResultResponse{CommandID: value.ID, State: string(value.State), Result: gameClientBridgeCommandResultFromDomain(value.Result), UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeCancelFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeCancelResponse {
|
||||||
|
value = domain.CopyGameClientBridgeCommand(value)
|
||||||
|
return GameClientBridgeCancelResponse{CommandID: value.ID, State: string(value.State), Cancellation: gameClientBridgeCommandCancellationFromDomain(value.Cancellation), AuditReferences: copyStrings(value.AuditReferences), UpdatedAt: value.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeSnapshotIngestFromDomain(value domain.GameClientBridgeSnapshot) GameClientBridgeSnapshotIngestResponse {
|
||||||
|
return GameClientBridgeSnapshotIngestResponse{SnapshotID: value.ID, ProfileKey: value.ProfileKey, Type: value.Type, SchemaVersion: value.SchemaVersion, StreamKey: value.StreamKey, Sequence: value.Sequence, AcceptedAt: value.CreatedAt, ExpiresAt: value.ExpiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeAuditReferenceFromDomain(value domain.GameClientBridgeAuditReference) GameClientBridgeAuditReferenceResponse {
|
||||||
|
return GameClientBridgeAuditReferenceResponse{ID: value.ID, CommandID: value.CommandID, SnapshotID: value.SnapshotID, AuditEventID: value.AuditEventID, CreatedAt: value.CreatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeAuditReferencesFromDomain(values []domain.GameClientBridgeAuditReference) GameClientBridgeAuditReferenceListResponse {
|
||||||
|
items := make([]GameClientBridgeAuditReferenceResponse, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
items[index] = GameClientBridgeAuditReferenceFromDomain(value)
|
||||||
|
}
|
||||||
|
return GameClientBridgeAuditReferenceListResponse{Items: items, Count: len(items)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameClientBridgeStatusFromDomain(value domain.GameClientBridgeStatus) GameClientBridgeStatusResponse {
|
||||||
|
value = domain.CopyGameClientBridgeStatus(value)
|
||||||
|
profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles))
|
||||||
|
for index, profile := range value.Profiles {
|
||||||
|
profiles[index] = GameClientBridgeProfileDeclarationResponse{PluginID: profile.PluginID, ProfileKey: profile.ProfileKey, Available: profile.Available, Reason: profile.Reason, CommandTypes: nonNilStrings(profile.CommandTypes), SnapshotTypes: nonNilStrings(profile.SnapshotTypes), QueryTemplateKeys: nonNilStrings(profile.QueryTemplateKeys)}
|
||||||
|
}
|
||||||
|
return GameClientBridgeStatusResponse{ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Available: value.Available, Reason: value.Reason, Profiles: profiles}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeCommandResultFromDomain(value domain.GameClientBridgeResult) GameClientBridgeCommandResultResponse {
|
||||||
|
value = domain.CopyGameClientBridgeResult(value)
|
||||||
|
return GameClientBridgeCommandResultResponse{Status: string(value.Status), Summary: value.Summary, Payload: value.Payload, CompletedAt: value.CompletedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeCommandCancellationFromDomain(value domain.GameClientBridgeCancellation) GameClientBridgeCommandCancellationResponse {
|
||||||
|
return GameClientBridgeCommandCancellationResponse{RequestedBy: value.RequestedBy, Reason: value.Reason, CancelledAt: value.CancelledAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeRetentionFromDomain(value domain.GameClientBridgeRetention) GameClientBridgeRetentionResponse {
|
||||||
|
return GameClientBridgeRetentionResponse{KeepForSeconds: value.KeepForSeconds, MaxRecords: value.MaxRecords}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasGameClientBridgeResult(value domain.GameClientBridgeResult) bool {
|
||||||
|
return value.Status != "" || value.Summary != "" || value.Payload != nil || value.CompletedBy != "" || !value.CompletedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasGameClientBridgeCancellation(value domain.GameClientBridgeCancellation) bool {
|
||||||
|
return value.RequestedBy != "" || value.Reason != "" || !value.CancelledAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStrings(values []string) []string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]string, len(values))
|
||||||
|
copy(result, values)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonNilStrings(values []string) []string {
|
||||||
|
if values == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return copyStrings(values)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGameClientBridgeCompanionDeclarationRoundTripsWithoutMaterial(t *testing.T) {
|
||||||
|
body := GameClientBridgeManifestBody{
|
||||||
|
CommandRetentionSeconds: 86400,
|
||||||
|
MaxCommands: 1000,
|
||||||
|
Companion: &GameClientBridgeCompanionDeclarationBody{
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
ConfigTemplateKey: "client-config",
|
||||||
|
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||||
|
ConfigFormat: "yaml",
|
||||||
|
PlatformBaseURLSource: "run-control",
|
||||||
|
RegistrationProof: "hmac-sha256",
|
||||||
|
ProofMaterialSource: "component-package",
|
||||||
|
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||||
|
SessionMode: "component-session",
|
||||||
|
TLSPolicy: "verify-system-roots",
|
||||||
|
HeartbeatIntervalSeconds: 30,
|
||||||
|
CommandPollIntervalSeconds: 5,
|
||||||
|
RequestTimeoutSeconds: 15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
domainValue := body.ToDomain()
|
||||||
|
if domainValue.Companion.ProfileKey != "scum-client-manager" || domainValue.Companion.TLSPolicy != "verify-system-roots" {
|
||||||
|
t.Fatalf("companion declaration conversion lost fields: %+v", domainValue.Companion)
|
||||||
|
}
|
||||||
|
projection := gameClientBridgeManifestFromDomain(domainValue)
|
||||||
|
encoded, err := json.Marshal(projection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal companion declaration: %v", err)
|
||||||
|
}
|
||||||
|
serialized := string(encoded)
|
||||||
|
for _, forbidden := range []string{"authKey", "componentKey", "sessionToken", "credential", "secretRef", "hostPath", "runSocket"} {
|
||||||
|
if strings.Contains(serialized, forbidden) {
|
||||||
|
t.Fatalf("companion declaration exposed %q: %s", forbidden, serialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGameClientBridgeRequestConversionsInjectScopeAndCopyPayloads(t *testing.T) {
|
||||||
|
payload := map[string]any{"players": []any{map[string]any{"name": "Alice"}}}
|
||||||
|
queue := GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "player.lookup", Payload: payload, IdempotencyKey: "lookup-1", Priority: 5, ExpiresAt: time.Now().UTC().Add(time.Minute)}.ToDomain("server-1", "game.scum")
|
||||||
|
claim := GameClientBridgeClaimRequest{SessionToken: "session", Limit: 5}.ToDomain()
|
||||||
|
ack := GameClientBridgeAckRequest{SessionToken: "session", FencingToken: 7}.ToDomain("command-1")
|
||||||
|
result := GameClientBridgeResultRequest{SessionToken: "session", FencingToken: 7, Status: "succeeded", Payload: payload}.ToDomain("command-1")
|
||||||
|
cancel := GameClientBridgeCancelRequest{Reason: "operator request"}.ToDomain("command-1")
|
||||||
|
snapshot := GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 2, Payload: payload, KeepForSeconds: 60, MaxRecords: 4}.ToDomain()
|
||||||
|
query := GameClientBridgeSnapshotQuery{ProfileKey: "scum-client", Type: "players", StreamKey: "current", Limit: 10}.ToDomain("server-1", "game.scum")
|
||||||
|
|
||||||
|
if queue.ServerInstanceID != "server-1" || queue.PluginID != "game.scum" || claim.Limit != 5 || ack.CommandID != "command-1" || result.CommandID != "command-1" || cancel.CommandID != "command-1" || snapshot.Retention.MaxRecords != 4 || query.ServerInstanceID != "server-1" || query.PluginID != "game.scum" {
|
||||||
|
t.Fatalf("request conversion lost route or body fields: queue=%#v claim=%#v ack=%#v result=%#v cancel=%#v snapshot=%#v query=%#v", queue, claim, ack, result, cancel, snapshot, query)
|
||||||
|
}
|
||||||
|
queue.Payload["players"].([]any)[0].(map[string]any)["name"] = "queue"
|
||||||
|
result.Payload["players"].([]any)[0].(map[string]any)["name"] = "result"
|
||||||
|
snapshot.Payload["players"].([]any)[0].(map[string]any)["name"] = "snapshot"
|
||||||
|
if got := payload["players"].([]any)[0].(map[string]any)["name"]; got != "Alice" {
|
||||||
|
t.Fatalf("request conversion aliases source payload: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeBrowserProjectionsAreCompleteAndOmitInternalData(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||||
|
command := domain.GameClientBridgeCommand{
|
||||||
|
ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "internal command payload"}, IdempotencyKey: "internal-idempotency", Priority: 9,
|
||||||
|
State: domain.GameClientBridgeCommandCancelled, ApprovalState: domain.GameClientBridgeApprovalApproved, RequesterID: "operator-1",
|
||||||
|
Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", DeploymentGeneration: 9, FencingToken: 42, LeaseExpiresAt: now.Add(time.Minute)},
|
||||||
|
Result: domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled safely", Payload: map[string]any{"code": "cancelled"}, CompletedBy: "internal-completing-session", CompletedAt: now.Add(3 * time.Minute)},
|
||||||
|
Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator-2", Reason: "operator request", CancelledAt: now.Add(2 * time.Minute)},
|
||||||
|
AuditReferences: []string{"audit-1"}, ExpiresAt: now.Add(10 * time.Minute), CreatedAt: now, UpdatedAt: now.Add(3 * time.Minute), CompletedAt: now.Add(3 * time.Minute),
|
||||||
|
}
|
||||||
|
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 2, SourceSessionID: "internal-source-session", ObservedAt: now, Payload: map[string]any{"players": []any{map[string]any{"name": "Alice"}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 10}, AuditReferences: []string{"audit-2"}, CreatedAt: now.Add(time.Second), ExpiresAt: now.Add(time.Hour)}
|
||||||
|
|
||||||
|
commandProjection := GameClientBridgeCommandFromDomain(command)
|
||||||
|
snapshotProjection := GameClientBridgeSnapshotFromDomain(snapshot)
|
||||||
|
if commandProjection.Result == nil || commandProjection.Result.Status != "cancelled" || commandProjection.Cancellation == nil || commandProjection.Cancellation.Reason != "operator request" || commandProjection.Priority != 9 || commandProjection.AuditReferences[0] != "audit-1" || !commandProjection.CompletedAt.Equal(command.CompletedAt) {
|
||||||
|
t.Fatalf("incomplete command projection: %#v", commandProjection)
|
||||||
|
}
|
||||||
|
if snapshotProjection.ProfileKey != "scum-client" || snapshotProjection.Retention.KeepForSeconds != 3600 || snapshotProjection.AuditReferences[0] != "audit-2" || !snapshotProjection.CreatedAt.Equal(snapshot.CreatedAt) {
|
||||||
|
t.Fatalf("incomplete snapshot projection: %#v", snapshotProjection)
|
||||||
|
}
|
||||||
|
|
||||||
|
commandProjection.Result.Payload["code"] = "changed"
|
||||||
|
commandProjection.AuditReferences[0] = "changed"
|
||||||
|
snapshotProjection.Payload["players"].([]any)[0].(map[string]any)["name"] = "changed"
|
||||||
|
snapshotProjection.AuditReferences[0] = "changed"
|
||||||
|
if command.Result.Payload["code"] != "cancelled" || command.AuditReferences[0] != "audit-1" || snapshot.Payload["players"].([]any)[0].(map[string]any)["name"] != "Alice" || snapshot.AuditReferences[0] != "audit-2" {
|
||||||
|
t.Fatal("browser projection aliases domain data")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(struct {
|
||||||
|
Command GameClientBridgeCommandResponse `json:"command"`
|
||||||
|
Snapshot GameClientBridgeSnapshotResponse `json:"snapshot"`
|
||||||
|
}{commandProjection, snapshotProjection})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal projections: %v", err)
|
||||||
|
}
|
||||||
|
text := string(encoded)
|
||||||
|
for _, forbidden := range []string{"internal-session-secret", "internal-installation", "internal-completing-session", "internal-source-session", "internal-idempotency", "internal command payload", "fencingToken", "deploymentGeneration", "sourceSessionId", "completedBy", "idempotencyKey", "leaseExpiresAt"} {
|
||||||
|
if strings.Contains(text, forbidden) {
|
||||||
|
t.Fatalf("browser projection leaked %q: %s", forbidden, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||||
|
command := domain.GameClientBridgeCommand{
|
||||||
|
ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, Priority: 3, State: domain.GameClientBridgeCommandSucceeded,
|
||||||
|
Claim: domain.GameClientBridgeClaim{SessionID: "private-session", FencingToken: 7, ClaimedAt: now, AcknowledgedAt: now.Add(time.Second), LeaseExpiresAt: now.Add(time.Minute)},
|
||||||
|
Result: domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "ok", Payload: map[string]any{"healthy": true}, CompletedBy: "private-session", CompletedAt: now.Add(2 * time.Second)},
|
||||||
|
Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator", Reason: "superseded", CancelledAt: now.Add(3 * time.Second)}, AuditReferences: []string{"audit-1"}, ExpiresAt: now.Add(5 * time.Minute), UpdatedAt: now.Add(2 * time.Second), CompletedAt: now.Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 4, CreatedAt: now, ExpiresAt: now.Add(time.Hour)}
|
||||||
|
|
||||||
|
claim := GameClientBridgeClaimBatchFromDomain([]domain.GameClientBridgeCommand{command})
|
||||||
|
ack := GameClientBridgeAckFromDomain(command)
|
||||||
|
result := GameClientBridgeResultFromDomain(command)
|
||||||
|
cancel := GameClientBridgeCancelFromDomain(command)
|
||||||
|
ingest := GameClientBridgeSnapshotIngestFromDomain(snapshot)
|
||||||
|
commands := GameClientBridgeCommandsFromDomain([]domain.GameClientBridgeCommand{command})
|
||||||
|
snapshots := GameClientBridgeSnapshotsFromDomain([]domain.GameClientBridgeSnapshot{snapshot})
|
||||||
|
audits := GameClientBridgeAuditReferencesFromDomain([]domain.GameClientBridgeAuditReference{{ID: "reference-1", CommandID: "command-1", AuditEventID: "audit-1", CreatedAt: now}})
|
||||||
|
if claim.Count != 1 || claim.Items[0].FencingToken != 7 || claim.Items[0].ProfileKey != "scum-client" || ack.CommandID != "command-1" || !ack.AcknowledgedAt.Equal(now.Add(time.Second)) || result.Result.Summary != "ok" || cancel.Cancellation.Reason != "superseded" || ingest.SnapshotID != "snapshot-1" || commands.Count != 1 || snapshots.Count != 1 || audits.Count != 1 {
|
||||||
|
t.Fatalf("unexpected responses: claim=%#v ack=%#v result=%#v cancel=%#v ingest=%#v", claim, ack, result, cancel, ingest)
|
||||||
|
}
|
||||||
|
claim.Items[0].Payload["scope"] = "changed"
|
||||||
|
result.Result.Payload["healthy"] = false
|
||||||
|
if command.Payload["scope"] != "health" || command.Result.Payload["healthy"] != true {
|
||||||
|
t.Fatal("companion response aliases domain payload")
|
||||||
|
}
|
||||||
|
for name, value := range map[string]any{"claim": claim, "ack": ack, "result": result, "cancel": cancel, "ingest": ingest, "commands": commands, "snapshots": snapshots, "audits": audits} {
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal %s response: %v", name, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(encoded), "private-session") || strings.Contains(string(encoded), "sessionId") || strings.Contains(string(encoded), "completedBy") {
|
||||||
|
t.Fatalf("%s response exposes a private session: %s", name, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if GameClientBridgeCommandsFromDomain(nil).Items == nil || GameClientBridgeSnapshotsFromDomain(nil).Items == nil || GameClientBridgeClaimBatchFromDomain(nil).Items == nil || GameClientBridgeAuditReferencesFromDomain(nil).Items == nil {
|
||||||
|
t.Fatal("list converters must serialize empty items as [] instead of null")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,10 @@ type RunJobExecutionInputBody struct {
|
|||||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
|
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
|
||||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
|
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
|
||||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||||
|
PluginID string `json:"pluginId,omitempty"`
|
||||||
|
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
|
||||||
|
TargetVersion string `json:"targetVersion,omitempty"`
|
||||||
|
Inputs map[string]string `json:"inputs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunJobExecutionResultBody struct {
|
type RunJobExecutionResultBody struct {
|
||||||
@@ -525,7 +529,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
|||||||
State: assignment.State,
|
State: assignment.State,
|
||||||
Progress: progressReportFromDomain(assignment.Progress),
|
Progress: progressReportFromDomain(assignment.Progress),
|
||||||
ResultRef: assignment.ResultRef,
|
ResultRef: assignment.ResultRef,
|
||||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds},
|
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs)},
|
||||||
LeaseToken: assignment.LeaseToken,
|
LeaseToken: assignment.LeaseToken,
|
||||||
Attempt: assignment.Attempt,
|
Attempt: assignment.Attempt,
|
||||||
MaxAttempts: assignment.MaxAttempts,
|
MaxAttempts: assignment.MaxAttempts,
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ type RemoteAdapterRequestBody struct {
|
|||||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||||
MaxAttempts int `json:"maxAttempts,omitempty"`
|
MaxAttempts int `json:"maxAttempts,omitempty"`
|
||||||
IdempotencyKey string `json:"idempotencyKey"`
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
InputRef string `json:"inputRef,omitempty"`
|
||||||
|
Inputs map[string]string `json:"inputs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoteAdapterResponse struct {
|
type RemoteAdapterResponse struct {
|
||||||
@@ -149,7 +151,7 @@ func RemoteAdapterDeclarationsFromDomain(declarations []domain.RemoteAdapterDecl
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (request RemoteAdapterRequestBody) ToDomain(serverInstanceID string) domain.RemoteAdapterRequest {
|
func (request RemoteAdapterRequestBody) ToDomain(serverInstanceID string) domain.RemoteAdapterRequest {
|
||||||
return domain.RemoteAdapterRequest{ServerInstanceID: serverInstanceID, DeclarationKey: request.DeclarationKey, TargetKey: request.TargetKey, Capability: request.Capability, TimeoutSeconds: request.TimeoutSeconds, MaxAttempts: request.MaxAttempts, IdempotencyKey: request.IdempotencyKey}
|
return domain.RemoteAdapterRequest{ServerInstanceID: serverInstanceID, DeclarationKey: request.DeclarationKey, TargetKey: request.TargetKey, Capability: request.Capability, TimeoutSeconds: request.TimeoutSeconds, MaxAttempts: request.MaxAttempts, IdempotencyKey: request.IdempotencyKey, InputRef: request.InputRef, Inputs: domain.CopyStringMap(request.Inputs)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterResponse {
|
func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterResponse {
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CapacityAdmissionRequest struct {
|
||||||
|
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||||
|
RunEndpointID string `json:"runEndpointId,omitempty"`
|
||||||
|
Capability string `json:"capability"`
|
||||||
|
TargetKey string `json:"targetKey,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CapacityAdmissionDecisionResponse struct {
|
||||||
|
Accepted bool `json:"accepted"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||||
|
RunEndpointID string `json:"runEndpointId,omitempty"`
|
||||||
|
Capability string `json:"capability"`
|
||||||
|
TargetKey string `json:"targetKey,omitempty"`
|
||||||
|
MaxJobs int `json:"maxJobs"`
|
||||||
|
RunningJobs int `json:"runningJobs"`
|
||||||
|
QueuedJobs int `json:"queuedJobs"`
|
||||||
|
PressureCodes []string `json:"pressureCodes,omitempty"`
|
||||||
|
CheckedAt time.Time `json:"checkedAt"`
|
||||||
|
AlertID string `json:"alertId,omitempty"`
|
||||||
|
AuditEventID string `json:"auditEventId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EndpointCapacityProjectionResponse struct {
|
||||||
|
RunEndpointID string `json:"runEndpointId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
MaxJobs int `json:"maxJobs"`
|
||||||
|
RunningJobs int `json:"runningJobs"`
|
||||||
|
QueuedJobs int `json:"queuedJobs"`
|
||||||
|
LogBacklogBatches int `json:"logBacklogBatches,omitempty"`
|
||||||
|
ArtifactBacklogChunks int `json:"artifactBacklogChunks,omitempty"`
|
||||||
|
PressureCodes []string `json:"pressureCodes,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
|
||||||
|
LastAdmissionDecision string `json:"lastAdmissionDecision,omitempty"`
|
||||||
|
LastAdmissionReason string `json:"lastAdmissionReason,omitempty"`
|
||||||
|
LastAdmissionCheckedAt time.Time `json:"lastAdmissionCheckedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductionCapacitySummaryResponse struct {
|
||||||
|
Endpoints []EndpointCapacityProjectionResponse `json:"endpoints"`
|
||||||
|
TotalMaxJobs int `json:"totalMaxJobs"`
|
||||||
|
TotalRunningJobs int `json:"totalRunningJobs"`
|
||||||
|
TotalQueuedJobs int `json:"totalQueuedJobs"`
|
||||||
|
ActiveAlerts int `json:"activeAlerts"`
|
||||||
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SourceKind string `json:"sourceKind"`
|
||||||
|
SourceID string `json:"sourceId"`
|
||||||
|
RuleKey string `json:"ruleKey"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
OccurrenceCount int `json:"occurrenceCount"`
|
||||||
|
Retryable bool `json:"retryable"`
|
||||||
|
RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"`
|
||||||
|
LastJobID string `json:"lastJobId,omitempty"`
|
||||||
|
LastAuditEventID string `json:"lastAuditEventId,omitempty"`
|
||||||
|
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||||
|
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
|
||||||
|
AcknowledgedAt time.Time `json:"acknowledgedAt,omitempty"`
|
||||||
|
ResolvedBy string `json:"resolvedBy,omitempty"`
|
||||||
|
ResolvedAt time.Time `json:"resolvedAt,omitempty"`
|
||||||
|
ResolutionNote string `json:"resolutionNote,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertListResponse struct {
|
||||||
|
Items []AlertResponse `json:"items"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertAcknowledgeRequest struct {
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertResolveRequest struct {
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertRetryRequest struct {
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertRetryResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Alert AlertResponse `json:"alert"`
|
||||||
|
Decision CapacityAdmissionDecisionResponse `json:"decision"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleActionRequest struct {
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
TargetVersion string `json:"targetVersion,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
Confirmed bool `json:"confirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleInstallationResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
CurrentVersion string `json:"currentVersion,omitempty"`
|
||||||
|
TargetVersion string `json:"targetVersion,omitempty"`
|
||||||
|
PreviousVersion string `json:"previousVersion,omitempty"`
|
||||||
|
DesiredState string `json:"desiredState"`
|
||||||
|
CurrentState string `json:"currentState"`
|
||||||
|
LastOperation string `json:"lastOperation,omitempty"`
|
||||||
|
Compatibility string `json:"compatibility,omitempty"`
|
||||||
|
DependencyState string `json:"dependencyState,omitempty"`
|
||||||
|
JobID string `json:"jobId,omitempty"`
|
||||||
|
AlertID string `json:"alertId,omitempty"`
|
||||||
|
AuditEventID string `json:"auditEventId,omitempty"`
|
||||||
|
FailureReason string `json:"failureReason,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleListResponse struct {
|
||||||
|
Items []PluginLifecycleInstallationResponse `json:"items"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleActionResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Installation PluginLifecycleInstallationResponse `json:"installation"`
|
||||||
|
Job JobResponse `json:"job"`
|
||||||
|
Decision CapacityAdmissionDecisionResponse `json:"decision"`
|
||||||
|
Alert *AlertResponse `json:"alert,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffPreviewResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId,omitempty"`
|
||||||
|
ProviderID string `json:"providerId,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
ConfigVersion int `json:"configVersion"`
|
||||||
|
CurrentConfigChecksum string `json:"currentConfigChecksum,omitempty"`
|
||||||
|
ProposedConfig string `json:"proposedConfig,omitempty"`
|
||||||
|
DiffSummary string `json:"diffSummary"`
|
||||||
|
State string `json:"state"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
ApprovedBy string `json:"approvedBy,omitempty"`
|
||||||
|
ApprovedAt time.Time `json:"approvedAt,omitempty"`
|
||||||
|
CancelledBy string `json:"cancelledBy,omitempty"`
|
||||||
|
CancelledAt time.Time `json:"cancelledAt,omitempty"`
|
||||||
|
JobID string `json:"jobId,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffListResponse struct {
|
||||||
|
Items []AIConfigDiffPreviewResponse `json:"items"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffApprovalRequest struct {
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffApprovalResponse struct {
|
||||||
|
Preview AIConfigDiffPreviewResponse `json:"preview"`
|
||||||
|
Dispatch ServerConfigWriteDispatchResponse `json:"dispatch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request CapacityAdmissionRequest) ToDomain() domain.CapacityAdmissionRequest {
|
||||||
|
return domain.CapacityAdmissionRequest{ServerInstanceID: request.ServerInstanceID, RunEndpointID: request.RunEndpointID, Capability: request.Capability, TargetKey: request.TargetKey, IdempotencyKey: request.IdempotencyKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CapacityDecisionFromDomain(decision domain.CapacityAdmissionDecision) CapacityAdmissionDecisionResponse {
|
||||||
|
decision = domain.CopyCapacityAdmissionDecision(decision)
|
||||||
|
return CapacityAdmissionDecisionResponse{Accepted: decision.Accepted, State: string(decision.State), Reason: decision.Reason, RetryAfterSeconds: decision.RetryAfterSeconds, ServerInstanceID: decision.ServerInstanceID, RunEndpointID: decision.RunEndpointID, Capability: decision.Capability, TargetKey: decision.TargetKey, MaxJobs: decision.MaxJobs, RunningJobs: decision.RunningJobs, QueuedJobs: decision.QueuedJobs, PressureCodes: pressureCodesFromDomain(decision.PressureCodes), CheckedAt: decision.CheckedAt, AlertID: decision.AlertID, AuditEventID: decision.AuditEventID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProductionCapacityFromDomain(summary domain.ProductionCapacitySummary) ProductionCapacitySummaryResponse {
|
||||||
|
summary = domain.CopyProductionCapacitySummary(summary)
|
||||||
|
items := make([]EndpointCapacityProjectionResponse, len(summary.Endpoints))
|
||||||
|
for i, endpoint := range summary.Endpoints {
|
||||||
|
items[i] = EndpointCapacityProjectionResponse{RunEndpointID: endpoint.RunEndpointID, DisplayName: endpoint.DisplayName, Status: string(endpoint.Status), Capabilities: endpoint.Capabilities, MaxJobs: endpoint.MaxJobs, RunningJobs: endpoint.RunningJobs, QueuedJobs: endpoint.QueuedJobs, LogBacklogBatches: endpoint.LogBacklogBatches, ArtifactBacklogChunks: endpoint.ArtifactBacklogChunks, PressureCodes: pressureCodesFromDomain(endpoint.PressureCodes), Summary: endpoint.Summary, LastHeartbeatAt: endpoint.LastHeartbeatAt, LastAdmissionDecision: string(endpoint.LastAdmissionDecision), LastAdmissionReason: endpoint.LastAdmissionReason, LastAdmissionCheckedAt: endpoint.LastAdmissionCheckedAt}
|
||||||
|
}
|
||||||
|
return ProductionCapacitySummaryResponse{Endpoints: items, TotalMaxJobs: summary.TotalMaxJobs, TotalRunningJobs: summary.TotalRunningJobs, TotalQueuedJobs: summary.TotalQueuedJobs, ActiveAlerts: summary.ActiveAlerts, GeneratedAt: summary.GeneratedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AlertFromDomain(alert domain.AlertRecord) AlertResponse {
|
||||||
|
alert = domain.CopyAlertRecord(alert)
|
||||||
|
return AlertResponse{ID: alert.ID, SourceKind: alert.SourceKind, SourceID: alert.SourceID, RuleKey: alert.RuleKey, Severity: string(alert.Severity), State: string(alert.State), Title: alert.Title, Message: alert.Message, OccurrenceCount: alert.OccurrenceCount, Retryable: alert.Retryable, RetryAfterSeconds: alert.RetryAfterSeconds, LastJobID: alert.LastJobID, LastAuditEventID: alert.LastAuditEventID, LastSeenAt: alert.LastSeenAt, AcknowledgedBy: alert.AcknowledgedBy, AcknowledgedAt: alert.AcknowledgedAt, ResolvedBy: alert.ResolvedBy, ResolvedAt: alert.ResolvedAt, ResolutionNote: alert.ResolutionNote, CreatedAt: alert.CreatedAt, UpdatedAt: alert.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AlertListFromDomain(alerts []domain.AlertRecord) AlertListResponse {
|
||||||
|
items := make([]AlertResponse, len(alerts))
|
||||||
|
for i, alert := range alerts {
|
||||||
|
items[i] = AlertFromDomain(alert)
|
||||||
|
}
|
||||||
|
return AlertListResponse{Items: items, Count: len(items)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AlertRetryFromDomain(result domain.AlertRetryResult) AlertRetryResponse {
|
||||||
|
result = domain.CopyAlertRetryResult(result)
|
||||||
|
return AlertRetryResponse{Status: result.Status, Alert: AlertFromDomain(result.Alert), Decision: CapacityDecisionFromDomain(result.Decision)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request PluginLifecycleActionRequest) ToDomain(pluginID string) domain.PluginLifecycleRequest {
|
||||||
|
return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: request.Confirmed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PluginLifecycleFromDomain(installation domain.PluginLifecycleInstallation) PluginLifecycleInstallationResponse {
|
||||||
|
installation = domain.CopyPluginLifecycleInstallation(installation)
|
||||||
|
return PluginLifecycleInstallationResponse{ID: installation.ID, PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, CurrentVersion: installation.CurrentVersion, TargetVersion: installation.TargetVersion, PreviousVersion: installation.PreviousVersion, DesiredState: string(installation.DesiredState), CurrentState: string(installation.CurrentState), LastOperation: string(installation.LastOperation), Compatibility: installation.Compatibility, DependencyState: string(installation.DependencyState), JobID: installation.JobID, AlertID: installation.AlertID, AuditEventID: installation.AuditEventID, FailureReason: installation.FailureReason, CreatedAt: installation.CreatedAt, UpdatedAt: installation.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PluginLifecycleListFromDomain(installations []domain.PluginLifecycleInstallation) PluginLifecycleListResponse {
|
||||||
|
items := make([]PluginLifecycleInstallationResponse, len(installations))
|
||||||
|
for i, installation := range installations {
|
||||||
|
items[i] = PluginLifecycleFromDomain(installation)
|
||||||
|
}
|
||||||
|
return PluginLifecycleListResponse{Items: items, Count: len(items)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PluginLifecycleResultFromDomain(result domain.PluginLifecycleResult) PluginLifecycleActionResponse {
|
||||||
|
result = domain.CopyPluginLifecycleResult(result)
|
||||||
|
var alert *AlertResponse
|
||||||
|
if result.Alert != nil {
|
||||||
|
item := AlertFromDomain(*result.Alert)
|
||||||
|
alert = &item
|
||||||
|
}
|
||||||
|
return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job), Decision: CapacityDecisionFromDomain(result.Decision), Alert: alert}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse {
|
||||||
|
preview = domain.CopyAIConfigDiffPreview(preview)
|
||||||
|
return AIConfigDiffPreviewResponse{ID: preview.ID, RequestID: preview.RequestID, CreatedBy: preview.CreatedBy, ServerInstanceID: preview.ServerInstanceID, PluginID: preview.PluginID, ProviderID: preview.ProviderID, Model: preview.Model, Key: preview.Key, ConfigVersion: preview.ConfigVersion, CurrentConfigChecksum: preview.CurrentConfigChecksum, ProposedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, State: string(preview.State), ExpiresAt: preview.ExpiresAt, ApprovedBy: preview.ApprovedBy, ApprovedAt: preview.ApprovedAt, CancelledBy: preview.CancelledBy, CancelledAt: preview.CancelledAt, JobID: preview.JobID, CreatedAt: preview.CreatedAt, UpdatedAt: preview.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AIConfigDiffListFromDomain(previews []domain.AIConfigDiffPreview) AIConfigDiffListResponse {
|
||||||
|
items := make([]AIConfigDiffPreviewResponse, len(previews))
|
||||||
|
for i, preview := range previews {
|
||||||
|
items[i] = AIConfigDiffFromDomain(preview)
|
||||||
|
}
|
||||||
|
return AIConfigDiffListResponse{Items: items, Count: len(items)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request AIConfigDiffApprovalRequest) ToDomain(diffID string) domain.AIConfigDiffApprovalRequest {
|
||||||
|
return domain.AIConfigDiffApprovalRequest{DiffID: diffID, IdempotencyKey: request.IdempotencyKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AIConfigDiffApprovalFromDomain(result domain.AIConfigDiffApprovalResult) AIConfigDiffApprovalResponse {
|
||||||
|
result = domain.CopyAIConfigDiffApprovalResult(result)
|
||||||
|
return AIConfigDiffApprovalResponse{Preview: AIConfigDiffFromDomain(result.Preview), Dispatch: ServerConfigWriteDispatchFromDomain(result.Dispatch)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pressureCodesFromDomain(codes []domain.CapacityPressureCode) []string {
|
||||||
|
if codes == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, len(codes))
|
||||||
|
for i, code := range codes {
|
||||||
|
out[i] = string(code)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+159
-3
@@ -129,7 +129,7 @@ type AIProviderResponse struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind domain.AIProviderKind `json:"kind"`
|
Kind domain.AIProviderKind `json:"kind"`
|
||||||
BaseURL string `json:"baseUrl"`
|
BaseURLConfigured bool `json:"baseUrlConfigured"`
|
||||||
APIKeyConfigured bool `json:"apiKeyConfigured"`
|
APIKeyConfigured bool `json:"apiKeyConfigured"`
|
||||||
Models []string `json:"models"`
|
Models []string `json:"models"`
|
||||||
DefaultModel string `json:"defaultModel,omitempty"`
|
DefaultModel string `json:"defaultModel,omitempty"`
|
||||||
@@ -196,6 +196,14 @@ type GamePluginManifestServerBody struct {
|
|||||||
|
|
||||||
type GamePluginManifestAIBody struct {
|
type GamePluginManifestAIBody struct {
|
||||||
Purposes []string `json:"purposes,omitempty"`
|
Purposes []string `json:"purposes,omitempty"`
|
||||||
|
Mediation string `json:"mediation,omitempty"`
|
||||||
|
ConfigWritePolicy string `json:"configWritePolicy,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GamePluginProductionLifecycleBody struct {
|
||||||
|
Operations []string `json:"operations"`
|
||||||
|
DependencyPolicy string `json:"dependencyPolicy"`
|
||||||
|
ApprovalRequired []string `json:"approvalRequired"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginRemoteAccessBody struct {
|
type GamePluginRemoteAccessBody struct {
|
||||||
@@ -206,6 +214,71 @@ type GamePluginRemoteAccessBody struct {
|
|||||||
LogTransfer bool `json:"logTransfer,omitempty"`
|
LogTransfer bool `json:"logTransfer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCommandDeclarationBody struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
ApprovalLevel string `json:"approvalLevel"`
|
||||||
|
PayloadSchemaRef string `json:"payloadSchemaRef"`
|
||||||
|
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
|
||||||
|
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||||
|
MaxPayloadBytes int `json:"maxPayloadBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotDeclarationBody struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
|
SchemaRef string `json:"schemaRef"`
|
||||||
|
KeepForSeconds int `json:"keepForSeconds"`
|
||||||
|
MaxRecords int `json:"maxRecords"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeQueryTemplateDeclarationBody struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
Engine string `json:"engine"`
|
||||||
|
TransportKey string `json:"transportKey"`
|
||||||
|
TargetKey string `json:"targetKey"`
|
||||||
|
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||||
|
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||||
|
MaxRows int `json:"maxRows"`
|
||||||
|
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgePageContractBody struct {
|
||||||
|
PageKey string `json:"pageKey"`
|
||||||
|
CommandTypes []string `json:"commandTypes,omitempty"`
|
||||||
|
SnapshotTypes []string `json:"snapshotTypes,omitempty"`
|
||||||
|
QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCompanionDeclarationBody struct {
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
ConfigTemplateKey string `json:"configTemplateKey"`
|
||||||
|
ConfigSchemaRef string `json:"configSchemaRef"`
|
||||||
|
ConfigFormat string `json:"configFormat"`
|
||||||
|
PlatformBaseURLSource string `json:"platformBaseUrlSource"`
|
||||||
|
RegistrationProof string `json:"registrationProof"`
|
||||||
|
ProofMaterialSource string `json:"proofMaterialSource"`
|
||||||
|
ProofMaterialEnv string `json:"proofMaterialEnv"`
|
||||||
|
SessionMode string `json:"sessionMode"`
|
||||||
|
TLSPolicy string `json:"tlsPolicy"`
|
||||||
|
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
|
||||||
|
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"`
|
||||||
|
RequestTimeoutSeconds int `json:"requestTimeoutSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type GamePluginManifestBody struct {
|
type GamePluginManifestBody struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -220,8 +293,10 @@ type GamePluginManifestBody struct {
|
|||||||
Actions PluginLifecycleActionsBody `json:"actions"`
|
Actions PluginLifecycleActionsBody `json:"actions"`
|
||||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||||
AI GamePluginManifestAIBody `json:"ai,omitempty"`
|
AI GamePluginManifestAIBody `json:"ai,omitempty"`
|
||||||
|
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
|
||||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||||
|
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginManifestRegistrationRequest struct {
|
type GamePluginManifestRegistrationRequest struct {
|
||||||
@@ -247,8 +322,10 @@ type GamePluginCreateRequest struct {
|
|||||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
AIPurposes []string `json:"aiPurposes,omitempty"`
|
AIPurposes []string `json:"aiPurposes,omitempty"`
|
||||||
|
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle,omitempty"`
|
||||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||||
|
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,8 +347,10 @@ type GamePluginResponse struct {
|
|||||||
Pages []GamePluginPageBody `json:"pages"`
|
Pages []GamePluginPageBody `json:"pages"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
AIPurposes []string `json:"aiPurposes"`
|
AIPurposes []string `json:"aiPurposes"`
|
||||||
|
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
|
||||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||||
|
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||||
Status domain.GamePluginStatus `json:"status"`
|
Status domain.GamePluginStatus `json:"status"`
|
||||||
}
|
}
|
||||||
@@ -299,8 +378,10 @@ type MarketplacePluginResponse struct {
|
|||||||
Pages []GamePluginPageBody `json:"pages"`
|
Pages []GamePluginPageBody `json:"pages"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
AIPurposes []string `json:"aiPurposes"`
|
AIPurposes []string `json:"aiPurposes"`
|
||||||
|
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
|
||||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||||
|
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||||
Status domain.GamePluginStatus `json:"status"`
|
Status domain.GamePluginStatus `json:"status"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
@@ -523,6 +604,9 @@ type RunCapacityResponse struct {
|
|||||||
MaxJobs int `json:"maxJobs"`
|
MaxJobs int `json:"maxJobs"`
|
||||||
RunningJobs int `json:"runningJobs"`
|
RunningJobs int `json:"runningJobs"`
|
||||||
QueuedJobs int `json:"queuedJobs"`
|
QueuedJobs int `json:"queuedJobs"`
|
||||||
|
LogBacklogBatches int `json:"logBacklogBatches,omitempty"`
|
||||||
|
ArtifactBacklogChunks int `json:"artifactBacklogChunks,omitempty"`
|
||||||
|
PressureCodes []string `json:"pressureCodes,omitempty"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,8 +913,10 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
|
|||||||
Actions: request.Manifest.Actions.ToDomain(),
|
Actions: request.Manifest.Actions.ToDomain(),
|
||||||
Pages: pagesToDomain(request.Manifest.Pages),
|
Pages: pagesToDomain(request.Manifest.Pages),
|
||||||
AI: request.Manifest.AI.ToDomain(),
|
AI: request.Manifest.AI.ToDomain(),
|
||||||
|
ProductionLifecycle: request.Manifest.ProductionLifecycle.ToDomain(),
|
||||||
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
||||||
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
|
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
|
||||||
|
GameClientBridge: request.Manifest.GameClientBridge.ToDomain(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -849,7 +935,11 @@ func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestS
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI {
|
func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI {
|
||||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes)}
|
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes), Mediation: ai.Mediation, ConfigWritePolicy: ai.ConfigWritePolicy}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lifecycle GamePluginProductionLifecycleBody) ToDomain() domain.GamePluginProductionLifecycle {
|
||||||
|
return domain.GamePluginProductionLifecycle{Operations: domain.CopyStringSlice(lifecycle.Operations), DependencyPolicy: lifecycle.DependencyPolicy, ApprovalRequired: domain.CopyStringSlice(lifecycle.ApprovalRequired)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAccess {
|
func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAccess {
|
||||||
@@ -862,6 +952,30 @@ func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAcces
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest {
|
||||||
|
commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands))
|
||||||
|
for index, command := range body.Commands {
|
||||||
|
commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes}
|
||||||
|
}
|
||||||
|
snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots))
|
||||||
|
for index, snapshot := range body.Snapshots {
|
||||||
|
snapshots[index] = domain.GameClientBridgeSnapshotDeclaration{Type: snapshot.Type, SchemaVersion: snapshot.SchemaVersion, SchemaRef: snapshot.SchemaRef, Retention: domain.GameClientBridgeRetention{KeepForSeconds: snapshot.KeepForSeconds, MaxRecords: snapshot.MaxRecords}}
|
||||||
|
}
|
||||||
|
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
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)}
|
||||||
|
}
|
||||||
|
companion := domain.GameClientBridgeCompanionDeclaration{}
|
||||||
|
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, Companion: companion}
|
||||||
|
}
|
||||||
|
|
||||||
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||||
return domain.PluginLifecycleActions{
|
return domain.PluginLifecycleActions{
|
||||||
Install: actions.Install,
|
Install: actions.Install,
|
||||||
@@ -891,8 +1005,10 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
|
|||||||
Pages: pagesToDomain(request.Pages),
|
Pages: pagesToDomain(request.Pages),
|
||||||
Tags: domain.CopyStringSlice(request.Tags),
|
Tags: domain.CopyStringSlice(request.Tags),
|
||||||
AIPurposes: domain.CopyStringSlice(request.AIPurposes),
|
AIPurposes: domain.CopyStringSlice(request.AIPurposes),
|
||||||
|
ProductionLifecycle: request.ProductionLifecycle.ToDomain(),
|
||||||
RemoteAccess: request.RemoteAccess.ToDomain(),
|
RemoteAccess: request.RemoteAccess.ToDomain(),
|
||||||
RuntimeProfiles: request.RuntimeProfiles.ToDomain(),
|
RuntimeProfiles: request.RuntimeProfiles.ToDomain(),
|
||||||
|
GameClientBridge: request.GameClientBridge.ToDomain(),
|
||||||
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
|
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1076,7 +1192,7 @@ func AIProviderFromDomain(provider domain.AIProvider) AIProviderResponse {
|
|||||||
ID: provider.ID,
|
ID: provider.ID,
|
||||||
Name: provider.Name,
|
Name: provider.Name,
|
||||||
Kind: provider.Kind,
|
Kind: provider.Kind,
|
||||||
BaseURL: provider.BaseURL,
|
BaseURLConfigured: provider.BaseURL != "",
|
||||||
APIKeyConfigured: provider.APIKeyRef != "",
|
APIKeyConfigured: provider.APIKeyRef != "",
|
||||||
Models: provider.Models,
|
Models: provider.Models,
|
||||||
DefaultModel: provider.DefaultModel,
|
DefaultModel: provider.DefaultModel,
|
||||||
@@ -1135,8 +1251,10 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
|
|||||||
Pages: pagesFromDomain(plugin.Pages),
|
Pages: pagesFromDomain(plugin.Pages),
|
||||||
Tags: plugin.Tags,
|
Tags: plugin.Tags,
|
||||||
AIPurposes: plugin.AIPurposes,
|
AIPurposes: plugin.AIPurposes,
|
||||||
|
ProductionLifecycle: productionLifecycleFromDomain(plugin.ProductionLifecycle),
|
||||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||||
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
||||||
|
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
|
||||||
ValidationViolations: plugin.ValidationViolations,
|
ValidationViolations: plugin.ValidationViolations,
|
||||||
Status: plugin.Status,
|
Status: plugin.Status,
|
||||||
}
|
}
|
||||||
@@ -1224,14 +1342,46 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl
|
|||||||
Pages: pagesFromDomain(plugin.Pages),
|
Pages: pagesFromDomain(plugin.Pages),
|
||||||
Tags: plugin.Tags,
|
Tags: plugin.Tags,
|
||||||
AIPurposes: plugin.AIPurposes,
|
AIPurposes: plugin.AIPurposes,
|
||||||
|
ProductionLifecycle: productionLifecycleFromDomain(plugin.ProductionLifecycle),
|
||||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||||
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
||||||
|
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
|
||||||
ValidationViolations: plugin.ValidationViolations,
|
ValidationViolations: plugin.ValidationViolations,
|
||||||
Status: plugin.Status,
|
Status: plugin.Status,
|
||||||
Source: plugin.Source,
|
Source: plugin.Source,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func productionLifecycleFromDomain(lifecycle domain.GamePluginProductionLifecycle) GamePluginProductionLifecycleBody {
|
||||||
|
lifecycle = domain.CopyGamePluginProductionLifecycle(lifecycle)
|
||||||
|
return GamePluginProductionLifecycleBody{Operations: lifecycle.Operations, DependencyPolicy: lifecycle.DependencyPolicy, ApprovalRequired: lifecycle.ApprovalRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) GameClientBridgeManifestBody {
|
||||||
|
value = domain.CopyGameClientBridgeManifest(value)
|
||||||
|
commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands))
|
||||||
|
for index, command := range value.Commands {
|
||||||
|
commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes}
|
||||||
|
}
|
||||||
|
snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots))
|
||||||
|
for index, snapshot := range value.Snapshots {
|
||||||
|
snapshots[index] = GameClientBridgeSnapshotDeclarationBody{Type: snapshot.Type, SchemaVersion: snapshot.SchemaVersion, SchemaRef: snapshot.SchemaRef, KeepForSeconds: snapshot.Retention.KeepForSeconds, MaxRecords: snapshot.Retention.MaxRecords}
|
||||||
|
}
|
||||||
|
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
var companion *GameClientBridgeCompanionDeclarationBody
|
||||||
|
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, Companion: companion}
|
||||||
|
}
|
||||||
|
|
||||||
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
||||||
items := make([]MarketplacePluginResponse, len(plugins))
|
items := make([]MarketplacePluginResponse, len(plugins))
|
||||||
for i, plugin := range plugins {
|
for i, plugin := range plugins {
|
||||||
@@ -1609,6 +1759,9 @@ func capacityFromDomain(capacity domain.RunCapacity) RunCapacityResponse {
|
|||||||
MaxJobs: capacity.MaxJobs,
|
MaxJobs: capacity.MaxJobs,
|
||||||
RunningJobs: capacity.RunningJobs,
|
RunningJobs: capacity.RunningJobs,
|
||||||
QueuedJobs: capacity.QueuedJobs,
|
QueuedJobs: capacity.QueuedJobs,
|
||||||
|
LogBacklogBatches: capacity.LogBacklogBatches,
|
||||||
|
ArtifactBacklogChunks: capacity.ArtifactBacklogChunks,
|
||||||
|
PressureCodes: domain.CopyStringSlice(capacity.PressureCodes),
|
||||||
Summary: capacity.Summary,
|
Summary: capacity.Summary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1618,6 +1771,9 @@ func capacityToDomain(capacity RunCapacityResponse) domain.RunCapacity {
|
|||||||
MaxJobs: capacity.MaxJobs,
|
MaxJobs: capacity.MaxJobs,
|
||||||
RunningJobs: capacity.RunningJobs,
|
RunningJobs: capacity.RunningJobs,
|
||||||
QueuedJobs: capacity.QueuedJobs,
|
QueuedJobs: capacity.QueuedJobs,
|
||||||
|
LogBacklogBatches: capacity.LogBacklogBatches,
|
||||||
|
ArtifactBacklogChunks: capacity.ArtifactBacklogChunks,
|
||||||
|
PressureCodes: domain.CopyStringSlice(capacity.PressureCodes),
|
||||||
Summary: capacity.Summary,
|
Summary: capacity.Summary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAIProviderResponseExposesOnlyKeyPresence(t *testing.T) {
|
func TestAIProviderResponseExposesOnlySecretPresence(t *testing.T) {
|
||||||
responseType := reflect.TypeOf(AIProviderResponse{})
|
responseType := reflect.TypeOf(AIProviderResponse{})
|
||||||
|
if _, ok := responseType.FieldByName("BaseURL"); ok {
|
||||||
|
t.Fatal("AI provider response must not expose the provider base URL")
|
||||||
|
}
|
||||||
if _, ok := responseType.FieldByName("APIKey"); ok {
|
if _, ok := responseType.FieldByName("APIKey"); ok {
|
||||||
t.Fatal("AI provider response must not expose raw API key")
|
t.Fatal("AI provider response must not expose raw API key")
|
||||||
}
|
}
|
||||||
@@ -21,6 +25,9 @@ func TestAIProviderResponseExposesOnlyKeyPresence(t *testing.T) {
|
|||||||
if _, ok := responseType.FieldByName("APIKeyConfigured"); !ok {
|
if _, ok := responseType.FieldByName("APIKeyConfigured"); !ok {
|
||||||
t.Fatal("AI provider response must expose API key presence")
|
t.Fatal("AI provider response must expose API key presence")
|
||||||
}
|
}
|
||||||
|
if _, ok := responseType.FieldByName("BaseURLConfigured"); !ok {
|
||||||
|
t.Fatal("AI provider response must expose base URL presence")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAIProviderFromDomainCopiesModels(t *testing.T) {
|
func TestAIProviderFromDomainCopiesModels(t *testing.T) {
|
||||||
@@ -47,6 +54,9 @@ func TestAIProviderFromDomainCopiesModels(t *testing.T) {
|
|||||||
if !response.APIKeyConfigured {
|
if !response.APIKeyConfigured {
|
||||||
t.Fatal("expected configured API key presence")
|
t.Fatal("expected configured API key presence")
|
||||||
}
|
}
|
||||||
|
if !response.BaseURLConfigured {
|
||||||
|
t.Fatal("expected configured base URL presence")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) {
|
func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) {
|
||||||
@@ -113,3 +123,49 @@ func TestGamePluginFromDomainCopiesRegistryMetadata(t *testing.T) {
|
|||||||
t.Fatalf("expected plugin response registry metadata to be copied, got %+v", plugin)
|
t.Fatalf("expected plugin response registry metadata to be copied, got %+v", plugin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||||
|
body := GameClientBridgeManifestBody{
|
||||||
|
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
|
||||||
|
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,
|
||||||
|
}},
|
||||||
|
CommandRetentionSeconds: 86400,
|
||||||
|
MaxCommands: 1000,
|
||||||
|
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
domainManifest := body.ToDomain()
|
||||||
|
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || domainManifest.QueryTemplates[0].MaxRows != 50 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
|
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
||||||
|
}
|
||||||
|
domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||||
|
if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
|
t.Fatal("query template page keys alias request DTO data")
|
||||||
|
}
|
||||||
|
domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup"
|
||||||
|
|
||||||
|
response := gameClientBridgeManifestFromDomain(domainManifest)
|
||||||
|
response.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||||
|
if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
|
t.Fatal("query template page keys alias domain data")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(response.QueryTemplates[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal safe query template projection: %v", err)
|
||||||
|
}
|
||||||
|
var projection map[string]any
|
||||||
|
if err := json.Unmarshal(encoded, &projection); err != nil {
|
||||||
|
t.Fatalf("decode safe query template projection: %v", err)
|
||||||
|
}
|
||||||
|
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutSeconds"}
|
||||||
|
if len(projection) != len(expectedFields) {
|
||||||
|
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
|
||||||
|
}
|
||||||
|
for _, field := range expectedFields {
|
||||||
|
if _, exists := projection[field]; !exists {
|
||||||
|
t.Fatalf("query template projection is missing %q: %s", field, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuntimeLogEventDeclarationRoundTripUsesSafeProjection(t *testing.T) {
|
||||||
|
body := GamePluginRuntimeProfilesBody{
|
||||||
|
LogEvents: []RuntimeLogEventBody{{
|
||||||
|
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||||
|
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := body.ToDomain()
|
||||||
|
if len(profiles.LogEvents) != 1 || profiles.LogEvents[0].EventType != "chat.message" || profiles.LogEvents[0].Severity != "info" {
|
||||||
|
t.Fatalf("log event conversion lost declaration fields: %+v", profiles.LogEvents)
|
||||||
|
}
|
||||||
|
profiles.LogEvents[0].Title = "Mutated"
|
||||||
|
if body.LogEvents[0].Title != "Chat message" {
|
||||||
|
t.Fatal("log event domain conversion aliases request DTO data")
|
||||||
|
}
|
||||||
|
|
||||||
|
projection := runtimeProfilesFromDomain(domain.GamePluginRuntimeProfiles{LogEvents: profiles.LogEvents})
|
||||||
|
encoded, err := json.Marshal(projection.LogEvents[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal log event projection: %v", err)
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||||
|
t.Fatalf("decode log event projection: %v", err)
|
||||||
|
}
|
||||||
|
expected := []string{"key", "title", "sourceKey", "eventType", "permission", "schemaRef", "retentionDays", "severity"}
|
||||||
|
if len(fields) != len(expected) {
|
||||||
|
t.Fatalf("log event projection contains unexpected fields: %s", encoded)
|
||||||
|
}
|
||||||
|
for _, field := range expected {
|
||||||
|
if _, exists := fields[field]; !exists {
|
||||||
|
t.Fatalf("log event projection is missing %q: %s", field, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,17 @@ type RuntimeLogSourceBody struct {
|
|||||||
RetentionDays int `json:"retentionDays,omitempty"`
|
RetentionDays int `json:"retentionDays,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeLogEventBody struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
SourceKey string `json:"sourceKey"`
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
SchemaRef string `json:"schemaRef"`
|
||||||
|
RetentionDays int `json:"retentionDays"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
type RuntimeTransportProfileBody struct {
|
type RuntimeTransportProfileBody struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
@@ -145,6 +156,7 @@ type GamePluginRuntimeProfilesBody struct {
|
|||||||
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
|
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
|
||||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||||
|
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
|
||||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||||
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -170,6 +182,9 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
|
|||||||
for _, item := range body.LogSources {
|
for _, item := range body.LogSources {
|
||||||
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||||
}
|
}
|
||||||
|
for _, item := range body.LogEvents {
|
||||||
|
profiles.LogEvents = append(profiles.LogEvents, domain.RuntimeLogEvent{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: domain.RuntimeLogEventSeverity(item.Severity)})
|
||||||
|
}
|
||||||
for _, item := range body.TransportProfiles {
|
for _, item := range body.TransportProfiles {
|
||||||
profiles.TransportProfiles = append(profiles.TransportProfiles, domain.RuntimeTransportProfile{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: domain.CopyStringSlice(item.Capabilities)})
|
profiles.TransportProfiles = append(profiles.TransportProfiles, domain.RuntimeTransportProfile{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: domain.CopyStringSlice(item.Capabilities)})
|
||||||
}
|
}
|
||||||
@@ -217,6 +232,9 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
|
|||||||
for _, item := range profiles.LogSources {
|
for _, item := range profiles.LogSources {
|
||||||
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||||
}
|
}
|
||||||
|
for _, item := range profiles.LogEvents {
|
||||||
|
body.LogEvents = append(body.LogEvents, RuntimeLogEventBody{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: string(item.Severity)})
|
||||||
|
}
|
||||||
for _, item := range profiles.TransportProfiles {
|
for _, item := range profiles.TransportProfiles {
|
||||||
body.TransportProfiles = append(body.TransportProfiles, RuntimeTransportProfileBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: item.Capabilities})
|
body.TransportProfiles = append(body.TransportProfiles, RuntimeTransportProfileBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: item.Capabilities})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,11 @@ Required model groups:
|
|||||||
- artifacts and chunks.
|
- artifacts and chunks.
|
||||||
- log streams and ingestion cursors.
|
- log streams and ingestion cursors.
|
||||||
- audit events.
|
- audit events.
|
||||||
|
- durable alerts and their acknowledgement/resolution audit links.
|
||||||
|
- server-bound plugin lifecycle installations and linked jobs.
|
||||||
|
- reviewable AI config diffs and approval fences.
|
||||||
|
|
||||||
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
|
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
|
||||||
|
# Durable Client Manager persistence
|
||||||
|
|
||||||
|
File and MySQL stores persist the Client Manager installation aggregate, component sessions, registration nonce fences, and lifecycle audit references. Session tokens are stored only as hashes; nonce and heartbeat sequence checks are bounded and restart-safe. Reset, ownership/endpoint reassignment, update activation, rollback, revoke, and uninstall fence or expire component sessions without deleting distribution or audit history.
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ type GamePlugin struct {
|
|||||||
Tags []string `json:"tags" db:"tags"`
|
Tags []string `json:"tags" db:"tags"`
|
||||||
// AIPurposes stores platform-mediated AI usage purposes.
|
// AIPurposes stores platform-mediated AI usage purposes.
|
||||||
AIPurposes []string `json:"aiPurposes" db:"ai_purposes"`
|
AIPurposes []string `json:"aiPurposes" db:"ai_purposes"`
|
||||||
|
// ProductionLifecycle stores declared server-bound plugin lifecycle governance.
|
||||||
|
ProductionLifecycle domain.GamePluginProductionLifecycle `json:"productionLifecycle" db:"production_lifecycle"`
|
||||||
// RemoteAccess stores plugin-declared remote access metadata.
|
// RemoteAccess stores plugin-declared remote access metadata.
|
||||||
RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"`
|
RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"`
|
||||||
// RuntimeProfiles stores validated manifest-declared runtime contracts.
|
// RuntimeProfiles stores validated manifest-declared runtime contracts.
|
||||||
@@ -287,6 +289,10 @@ type JobExecutionInput struct {
|
|||||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty" db:"remote_adapter_key"`
|
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty" db:"remote_adapter_key"`
|
||||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty" db:"remote_adapter_kind"`
|
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty" db:"remote_adapter_kind"`
|
||||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty" db:"timeout_seconds"`
|
TimeoutSeconds int `json:"timeoutSeconds,omitempty" db:"timeout_seconds"`
|
||||||
|
PluginID string `json:"pluginId,omitempty" db:"plugin_id"`
|
||||||
|
LifecycleOperation string `json:"lifecycleOperation,omitempty" db:"lifecycle_operation"`
|
||||||
|
TargetVersion string `json:"targetVersion,omitempty" db:"target_version"`
|
||||||
|
Inputs map[string]string `json:"inputs,omitempty" db:"inputs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobExecutionResult struct {
|
type JobExecutionResult struct {
|
||||||
@@ -428,6 +434,82 @@ type AuditEvent struct {
|
|||||||
|
|
||||||
func (AuditEvent) TableName() string { return "audit_events" }
|
func (AuditEvent) TableName() string { return "audit_events" }
|
||||||
|
|
||||||
|
type Alert struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
SourceKind string `json:"sourceKind" db:"source_kind"`
|
||||||
|
SourceID string `json:"sourceId" db:"source_id"`
|
||||||
|
RuleKey string `json:"ruleKey" db:"rule_key"`
|
||||||
|
Severity domain.AlertSeverity `json:"severity" db:"severity"`
|
||||||
|
State domain.AlertState `json:"state" db:"state"`
|
||||||
|
Title string `json:"title" db:"title"`
|
||||||
|
Message string `json:"message" db:"message"`
|
||||||
|
OccurrenceCount int `json:"occurrenceCount" db:"occurrence_count"`
|
||||||
|
Retryable bool `json:"retryable" db:"retryable"`
|
||||||
|
RetryAfterSeconds int `json:"retryAfterSeconds" db:"retry_after_seconds"`
|
||||||
|
LastJobID string `json:"lastJobId,omitempty" db:"last_job_id"`
|
||||||
|
LastAuditEventID string `json:"lastAuditEventId,omitempty" db:"last_audit_event_id"`
|
||||||
|
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
|
||||||
|
AcknowledgedBy string `json:"acknowledgedBy,omitempty" db:"acknowledged_by"`
|
||||||
|
AcknowledgedAt time.Time `json:"acknowledgedAt,omitempty" db:"acknowledged_at"`
|
||||||
|
ResolvedBy string `json:"resolvedBy,omitempty" db:"resolved_by"`
|
||||||
|
ResolvedAt time.Time `json:"resolvedAt,omitempty" db:"resolved_at"`
|
||||||
|
ResolutionNote string `json:"resolutionNote,omitempty" db:"resolution_note"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Alert) TableName() string { return "alerts" }
|
||||||
|
|
||||||
|
type PluginLifecycleInstallation struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
CurrentVersion string `json:"currentVersion,omitempty" db:"current_version"`
|
||||||
|
TargetVersion string `json:"targetVersion,omitempty" db:"target_version"`
|
||||||
|
PreviousVersion string `json:"previousVersion,omitempty" db:"previous_version"`
|
||||||
|
DesiredState domain.PluginLifecycleState `json:"desiredState" db:"desired_state"`
|
||||||
|
CurrentState domain.PluginLifecycleState `json:"currentState" db:"current_state"`
|
||||||
|
LastOperation domain.PluginLifecycleOperation `json:"lastOperation,omitempty" db:"last_operation"`
|
||||||
|
Compatibility string `json:"compatibility" db:"compatibility"`
|
||||||
|
DependencyState domain.DependencyState `json:"dependencyState" db:"dependency_state"`
|
||||||
|
JobID string `json:"jobId,omitempty" db:"job_id"`
|
||||||
|
AlertID string `json:"alertId,omitempty" db:"alert_id"`
|
||||||
|
AuditEventID string `json:"auditEventId,omitempty" db:"audit_event_id"`
|
||||||
|
FailureReason string `json:"failureReason,omitempty" db:"failure_reason"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PluginLifecycleInstallation) TableName() string { return "plugin_lifecycle_installations" }
|
||||||
|
|
||||||
|
type AIConfigDiff struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
RequestID string `json:"requestId" db:"request_id"`
|
||||||
|
CreatedBy string `json:"createdBy" db:"created_by"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
PluginID string `json:"pluginId,omitempty" db:"plugin_id"`
|
||||||
|
ProviderID string `json:"providerId,omitempty" db:"provider_id"`
|
||||||
|
Model string `json:"model,omitempty" db:"model"`
|
||||||
|
Key string `json:"key" db:"key"`
|
||||||
|
ConfigVersion int `json:"configVersion" db:"config_version"`
|
||||||
|
CurrentConfigChecksum string `json:"currentConfigChecksum" db:"current_config_checksum"`
|
||||||
|
ProposedConfig string `json:"proposedConfig" db:"proposed_config"`
|
||||||
|
DiffSummary string `json:"diffSummary" db:"diff_summary"`
|
||||||
|
State domain.AIConfigDiffState `json:"state" db:"state"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||||
|
ApprovedBy string `json:"approvedBy,omitempty" db:"approved_by"`
|
||||||
|
ApprovedAt time.Time `json:"approvedAt,omitempty" db:"approved_at"`
|
||||||
|
ApprovalIdempotencyKey string `json:"approvalIdempotencyKey,omitempty" db:"approval_idempotency_key"`
|
||||||
|
CancelledBy string `json:"cancelledBy,omitempty" db:"cancelled_by"`
|
||||||
|
CancelledAt time.Time `json:"cancelledAt,omitempty" db:"cancelled_at"`
|
||||||
|
JobID string `json:"jobId,omitempty" db:"job_id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AIConfigDiff) TableName() string { return "ai_config_diffs" }
|
||||||
|
|
||||||
func UserFromDomain(user domain.User) User {
|
func UserFromDomain(user domain.User) User {
|
||||||
user = domain.CopyUser(user)
|
user = domain.CopyUser(user)
|
||||||
return User{
|
return User{
|
||||||
@@ -511,6 +593,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePlugin {
|
|||||||
Pages: pagesFromDomain(plugin.Pages),
|
Pages: pagesFromDomain(plugin.Pages),
|
||||||
Tags: plugin.Tags,
|
Tags: plugin.Tags,
|
||||||
AIPurposes: plugin.AIPurposes,
|
AIPurposes: plugin.AIPurposes,
|
||||||
|
ProductionLifecycle: domain.CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle),
|
||||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||||
RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles),
|
RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles),
|
||||||
ValidationViolations: plugin.ValidationViolations,
|
ValidationViolations: plugin.ValidationViolations,
|
||||||
@@ -536,6 +619,7 @@ func (plugin GamePlugin) ToDomain() domain.GamePlugin {
|
|||||||
Pages: pagesToDomain(plugin.Pages),
|
Pages: pagesToDomain(plugin.Pages),
|
||||||
Tags: domain.CopyStringSlice(plugin.Tags),
|
Tags: domain.CopyStringSlice(plugin.Tags),
|
||||||
AIPurposes: domain.CopyStringSlice(plugin.AIPurposes),
|
AIPurposes: domain.CopyStringSlice(plugin.AIPurposes),
|
||||||
|
ProductionLifecycle: domain.CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle),
|
||||||
RemoteAccess: plugin.RemoteAccess.ToDomain(),
|
RemoteAccess: plugin.RemoteAccess.ToDomain(),
|
||||||
RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles),
|
RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles),
|
||||||
ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations),
|
ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations),
|
||||||
@@ -798,11 +882,11 @@ func (job Job) ToDomain() domain.Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput {
|
func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput {
|
||||||
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds}
|
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
|
func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
|
||||||
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds}
|
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
|
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
@@ -17,6 +18,9 @@ func TestTableNames(t *testing.T) {
|
|||||||
Artifact{}.TableName(): "artifacts",
|
Artifact{}.TableName(): "artifacts",
|
||||||
LogStream{}.TableName(): "log_streams",
|
LogStream{}.TableName(): "log_streams",
|
||||||
AuditEvent{}.TableName(): "audit_events",
|
AuditEvent{}.TableName(): "audit_events",
|
||||||
|
Alert{}.TableName(): "alerts",
|
||||||
|
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
||||||
|
AIConfigDiff{}.TableName(): "ai_config_diffs",
|
||||||
ClientManagerInstallation{}.TableName(): "client_manager_installations",
|
ClientManagerInstallation{}.TableName(): "client_manager_installations",
|
||||||
ClientManagerSession{}.TableName(): "client_manager_sessions",
|
ClientManagerSession{}.TableName(): "client_manager_sessions",
|
||||||
ClientManagerRegistrationNonce{}.TableName(): "client_manager_registration_nonces",
|
ClientManagerRegistrationNonce{}.TableName(): "client_manager_registration_nonces",
|
||||||
@@ -45,6 +49,11 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Tags: []string{"survival"},
|
Tags: []string{"survival"},
|
||||||
AIPurposes: []string{"logs.diagnose"},
|
AIPurposes: []string{"logs.diagnose"},
|
||||||
|
ProductionLifecycle: domain.GamePluginProductionLifecycle{
|
||||||
|
Operations: []string{"install", "upgrade", "rollback"},
|
||||||
|
DependencyPolicy: "required",
|
||||||
|
ApprovalRequired: []string{"rollback"},
|
||||||
|
},
|
||||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}},
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}},
|
||||||
Permissions: domain.PluginPermissions{
|
Permissions: domain.PluginPermissions{
|
||||||
Jobs: true,
|
Jobs: true,
|
||||||
@@ -61,6 +70,8 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
|
|||||||
roundTrip.Pages[0].Permissions[0] = "ai.invoke"
|
roundTrip.Pages[0].Permissions[0] = "ai.invoke"
|
||||||
roundTrip.Tags[0] = "mutated"
|
roundTrip.Tags[0] = "mutated"
|
||||||
roundTrip.AIPurposes[0] = "config.suggest"
|
roundTrip.AIPurposes[0] = "config.suggest"
|
||||||
|
roundTrip.ProductionLifecycle.Operations[0] = "retire"
|
||||||
|
roundTrip.ProductionLifecycle.ApprovalRequired[0] = "disable"
|
||||||
roundTrip.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] = "process.stop"
|
roundTrip.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] = "process.stop"
|
||||||
|
|
||||||
if source.RequiredRunCapabilities[0] != "process.start" {
|
if source.RequiredRunCapabilities[0] != "process.start" {
|
||||||
@@ -75,11 +86,45 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
|
|||||||
if row.DeclaredPermissions[0] != "server.logs.read" || row.Pages[0].Permissions[0] != "server.logs.read" || row.Tags[0] != "survival" || row.AIPurposes[0] != "logs.diagnose" {
|
if row.DeclaredPermissions[0] != "server.logs.read" || row.Pages[0].Permissions[0] != "server.logs.read" || row.Tags[0] != "survival" || row.AIPurposes[0] != "logs.diagnose" {
|
||||||
t.Fatalf("expected model plugin registry metadata to remain unchanged, got %+v", row)
|
t.Fatalf("expected model plugin registry metadata to remain unchanged, got %+v", row)
|
||||||
}
|
}
|
||||||
|
if source.ProductionLifecycle.Operations[0] != "install" || row.ProductionLifecycle.Operations[0] != "install" || source.ProductionLifecycle.ApprovalRequired[0] != "rollback" || row.ProductionLifecycle.ApprovalRequired[0] != "rollback" {
|
||||||
|
t.Fatalf("expected production lifecycle declaration to round-trip without aliasing, source=%+v row=%+v", source.ProductionLifecycle, row.ProductionLifecycle)
|
||||||
|
}
|
||||||
if source.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" || row.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" {
|
if source.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" || row.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" {
|
||||||
t.Fatalf("expected runtime profiles to round-trip without aliasing, source=%+v row=%+v", source.RuntimeProfiles, row.RuntimeProfiles)
|
t.Fatalf("expected runtime profiles to round-trip without aliasing, source=%+v row=%+v", source.RuntimeProfiles, row.RuntimeProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) {
|
||||||
|
source := domain.JobExecutionInput{
|
||||||
|
WorkspaceScope: "server-workspace",
|
||||||
|
PluginID: "game.scum",
|
||||||
|
LifecycleOperation: "upgrade",
|
||||||
|
TargetVersion: "2.0.0",
|
||||||
|
Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"},
|
||||||
|
}
|
||||||
|
|
||||||
|
row := executionInputFromDomain(source)
|
||||||
|
source.Inputs["playerId"] = "source-mutated"
|
||||||
|
if row.Inputs["playerId"] != "steam-123" {
|
||||||
|
t.Fatalf("expected model execution inputs to be isolated from source mutation, row=%+v", row.Inputs)
|
||||||
|
}
|
||||||
|
source.Inputs["playerId"] = "steam-123"
|
||||||
|
row.Inputs["playerId"] = "row-mutated"
|
||||||
|
if source.Inputs["playerId"] != "steam-123" {
|
||||||
|
t.Fatalf("expected source execution inputs to be isolated from model mutation, source=%+v", source.Inputs)
|
||||||
|
}
|
||||||
|
row.Inputs["playerId"] = "steam-123"
|
||||||
|
|
||||||
|
roundTrip := row.ToDomain()
|
||||||
|
if !reflect.DeepEqual(roundTrip, source) {
|
||||||
|
t.Fatalf("expected lifecycle execution metadata to round-trip, got %+v", roundTrip)
|
||||||
|
}
|
||||||
|
roundTrip.Inputs["playerId"] = "mutated"
|
||||||
|
if source.Inputs["playerId"] != "steam-123" || row.Inputs["playerId"] != "steam-123" {
|
||||||
|
t.Fatalf("expected execution inputs to round-trip without aliasing, source=%+v row=%+v", source.Inputs, row.Inputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAIProviderModelUsesKeyReference(t *testing.T) {
|
func TestAIProviderModelUsesKeyReference(t *testing.T) {
|
||||||
source := domain.AIProvider{
|
source := domain.AIProvider{
|
||||||
ID: "ai.openai",
|
ID: "ai.openai",
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ AI invocation responses must be bounded and must not include raw provider creden
|
|||||||
- `AIProviderCreateRequest`: create provider metadata with `apiKeyRef`, never raw key material.
|
- `AIProviderCreateRequest`: create provider metadata with `apiKeyRef`, never raw key material.
|
||||||
- `AIProviderUpdateRequest`: replace editable provider metadata while preserving status through the service layer.
|
- `AIProviderUpdateRequest`: replace editable provider metadata while preserving status through the service layer.
|
||||||
- `AIProviderStatusRequest`: set provider status to `active` or `disabled`.
|
- `AIProviderStatusRequest`: set provider status to `active` or `disabled`.
|
||||||
- `AIProviderResponse`: redacted provider response with `apiKeyConfigured` only; it does not expose the stored secret reference.
|
- `AIProviderResponse`: redacted provider response with `apiKeyConfigured` and `baseUrlConfigured` only; it does not expose the stored secret reference or provider endpoint.
|
||||||
- `AIProviderTestResponse`: local metadata validation result with `mode=metadata`; live external connectivity is deferred.
|
- `AIProviderTestResponse`: local metadata validation result with `mode=metadata`; live external connectivity is deferred.
|
||||||
- `AIProviderModelsResponse`: configured model list and default model, without credentials.
|
- `AIProviderModelsResponse`: configured model list and default model, without credentials.
|
||||||
|
|
||||||
Management endpoints must reject raw key-shaped values in `apiKeyRef`. Platform-mediated AI invocation is implemented through the platform service boundary; live external connectivity tests and remote model discovery are deferred to later changes.
|
Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode.
|
||||||
|
|
||||||
|
Provider failures create redacted audit/alert evidence and return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ Named control DTOs:
|
|||||||
|
|
||||||
Control payloads must remain small and must not include logs, artifact chunks, host paths, raw credentials, direct sockets, or long task results. Hello creates or updates run endpoint metadata and issues an in-memory platform session token. Heartbeat requires that active session token and may request capability refresh when the fingerprint changes.
|
Control payloads must remain small and must not include logs, artifact chunks, host paths, raw credentials, direct sockets, or long task results. Hello creates or updates run endpoint metadata and issues an in-memory platform session token. Heartbeat requires that active session token and may request capability refresh when the fingerprint changes.
|
||||||
|
|
||||||
|
Capacity reports include bounded `maxJobs`, `runningJobs`, `queuedJobs`, `logBacklogBatches`, `artifactBacklogChunks`, and enumerated pressure codes. They report queue and spool counts only, never log bodies, artifact chunks, machine paths, PIDs, sockets, credentials, or transport endpoints.
|
||||||
|
|
||||||
Control is the highest-priority run/platform path. Artifact/file transfer load must not delay heartbeat acceptance or mutate heartbeat capacity state through heavy payload fields.
|
Control is the highest-priority run/platform path. Artifact/file transfer load must not delay heartbeat acceptance or mutate heartbeat capacity state through heavy payload fields.
|
||||||
|
|
||||||
## Job
|
## Job
|
||||||
@@ -46,6 +48,10 @@ Named job DTOs:
|
|||||||
|
|
||||||
Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstanceId`, `capability`, `idempotencyKey`, lease token, attempt, progress, terminal state, message, error code, and result reference. Job payloads must not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies.
|
Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstanceId`, `capability`, `idempotencyKey`, lease token, attempt, progress, terminal state, message, error code, and result reference. Job payloads must not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies.
|
||||||
|
|
||||||
|
Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Install, enable, disable, upgrade, rollback, retire, and dependency-check remain Platform-authorized jobs; assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets.
|
||||||
|
|
||||||
|
Approved `config.write` and bounded `files.read`/`files.write` assignments carry logical keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local.
|
||||||
|
|
||||||
Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure.
|
Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure.
|
||||||
|
|
||||||
## Log Ingest
|
## Log Ingest
|
||||||
@@ -94,6 +100,14 @@ Artifact upload supports active run session validation, job/server-instance owne
|
|||||||
|
|
||||||
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
||||||
|
|
||||||
|
## Client Manager lifecycle channel
|
||||||
|
|
||||||
|
Client Manager lifecycle jobs use the independent capabilities `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Run obtains a fenced logical contract from `POST /api/v1/run/jobs/client-manager-input` and reads resumable artifact chunks from `POST /api/v1/run/jobs/client-manager-chunk`; these routes are separate from artifact upload, Run control, logs, and optional game-client traffic. The contract carries installation/profile, target, version/revision, checksum, deployment/key generations, fixed executable reference, bounded arguments/timeouts, and idempotency. For a plugin-declared companion profile it also carries a generic `companionConfig` materialization contract: safe relative template/schema/output references, the fenced component identity, declared component capabilities, Platform URL source, proof environment-variable name, component-session/TLS policy, and bounded timing values.
|
||||||
|
|
||||||
|
Run materializes the declared output such as `config.yaml` from the fenced values and its own configured Platform control URL. Source template values are not credentials and must not override the generated component identity or policy. The lifecycle input never contains the component proof itself, a component session, a browser credential, a host path, or a direct socket; proof remains inside the component package and is supplied to the supervised process only through the declared environment-variable name.
|
||||||
|
|
||||||
|
Run persists staging/active/previous slots and a local journal. It rejects stale lease/attempt/generation/target fences, traversal/link/device-file archives, checksum mismatches, undeclared executables, and arbitrary shell. Terminal results use `client-manager.deployed`, `client-manager.controlled`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or log spool acknowledgement.
|
||||||
|
|
||||||
## Game Client Bridge
|
## Game Client Bridge
|
||||||
|
|
||||||
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport.
|
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport.
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ type StoreSnapshot struct {
|
|||||||
AuditEvents []domain.AuditEvent `json:"auditEvents"`
|
AuditEvents []domain.AuditEvent `json:"auditEvents"`
|
||||||
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
||||||
Backups []domain.BackupRecord `json:"backups"`
|
Backups []domain.BackupRecord `json:"backups"`
|
||||||
|
Alerts []domain.AlertRecord `json:"alerts"`
|
||||||
|
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
||||||
|
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
|
||||||
|
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
|
||||||
|
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
|
||||||
|
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileStore struct {
|
type FileStore struct {
|
||||||
@@ -158,6 +164,33 @@ func (store *FileStore) Backups() BackupRepository {
|
|||||||
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
|
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *FileStore) Alerts() AlertRepository {
|
||||||
|
return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *FileStore) PluginLifecycles() PluginLifecycleRepository {
|
||||||
|
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *FileStore) AIConfigDiffs() AIConfigDiffRepository {
|
||||||
|
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *FileStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||||
|
return &persistentGameClientBridgeCommandRepository{
|
||||||
|
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||||
|
repository: store.MemoryStore.bridgeCommands,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *FileStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
|
||||||
|
return &persistentRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]{repository: store.MemoryStore.bridgeSnapshots, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
|
||||||
|
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
func (store *FileStore) load() error {
|
func (store *FileStore) load() error {
|
||||||
data, err := os.ReadFile(store.path)
|
data, err := os.ReadFile(store.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -224,6 +257,12 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
|||||||
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
||||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||||
|
Alerts: snapshotRepository(store.MemoryStore.alerts),
|
||||||
|
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||||
|
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||||
|
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||||
|
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||||
|
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +290,12 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
|||||||
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
||||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||||
|
loadRepository(store.MemoryStore.alerts, snapshot.Alerts)
|
||||||
|
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||||
|
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||||
|
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||||
|
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||||
|
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||||
}
|
}
|
||||||
|
|
||||||
type mutableRepository[T any, F any] interface {
|
type mutableRepository[T any, F any] interface {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryGameClientBridgeCommandRepository struct {
|
||||||
|
*memoryRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryGameClientBridgeCommandRepository() *memoryGameClientBridgeCommandRepository {
|
||||||
|
return &memoryGameClientBridgeCommandRepository{memoryRepository: newMemoryRepository(
|
||||||
|
func(command domain.GameClientBridgeCommand) string { return command.ID },
|
||||||
|
domain.CopyGameClientBridgeCommand,
|
||||||
|
matchGameClientBridgeCommand,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repository *memoryGameClientBridgeCommandRepository) GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error) {
|
||||||
|
values, err := repository.List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverInstanceID, RequesterID: requesterID, CommandType: commandType, IdempotencyKey: idempotencyKey})
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return domain.GameClientBridgeCommand{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return values[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type persistentGameClientBridgeCommandRepository struct {
|
||||||
|
*persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]
|
||||||
|
repository GameClientBridgeCommandRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repository *persistentGameClientBridgeCommandRepository) GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error) {
|
||||||
|
return repository.repository.GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryGameClientBridgeSnapshotRepository struct {
|
||||||
|
*memoryRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryGameClientBridgeSnapshotRepository() *memoryGameClientBridgeSnapshotRepository {
|
||||||
|
return &memoryGameClientBridgeSnapshotRepository{memoryRepository: newMemoryRepository(
|
||||||
|
func(snapshot domain.GameClientBridgeSnapshot) string { return snapshot.ID },
|
||||||
|
domain.CopyGameClientBridgeSnapshot,
|
||||||
|
matchGameClientBridgeSnapshot,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repository *memoryGameClientBridgeSnapshotRepository) List(filter domain.GameClientBridgeSnapshotFilter) ([]domain.GameClientBridgeSnapshot, error) {
|
||||||
|
limit := filter.Limit
|
||||||
|
filter.Limit = 0
|
||||||
|
values, err := repository.memoryRepository.List(filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.SliceStable(values, func(left, right int) bool {
|
||||||
|
if !values[left].ObservedAt.Equal(values[right].ObservedAt) {
|
||||||
|
return values[left].ObservedAt.After(values[right].ObservedAt)
|
||||||
|
}
|
||||||
|
if values[left].Sequence != values[right].Sequence {
|
||||||
|
return values[left].Sequence > values[right].Sequence
|
||||||
|
}
|
||||||
|
return values[left].ID < values[right].ID
|
||||||
|
})
|
||||||
|
if limit > 0 && len(values) > limit {
|
||||||
|
values = values[:limit]
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGameClientBridgeCommandRepositoryIdempotencyAndRetention(t *testing.T) {
|
||||||
|
store := NewMemoryStore()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", IdempotencyKey: "same-key", RequesterID: "user-1", State: domain.GameClientBridgeCommandSucceeded, Payload: map[string]any{"nested": map[string]any{"value": "original"}}, ExpiresAt: now.Add(-time.Minute), CompletedAt: now.Add(-time.Minute)}
|
||||||
|
if err := store.GameClientBridgeCommands().Create(command); err != nil {
|
||||||
|
t.Fatalf("create command: %v", err)
|
||||||
|
}
|
||||||
|
command.Payload["nested"].(map[string]any)["value"] = "mutated"
|
||||||
|
loaded, err := store.GameClientBridgeCommands().GetByIdempotency("server-1", "user-1", "announcement.send", "same-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by idempotency: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Payload["nested"].(map[string]any)["value"] != "original" {
|
||||||
|
t.Fatal("repository did not isolate nested payload")
|
||||||
|
}
|
||||||
|
for _, lookup := range [][4]string{{"server-2", "user-1", "announcement.send", "same-key"}, {"server-1", "user-2", "announcement.send", "same-key"}, {"server-1", "user-1", "diagnostic.safe", "same-key"}} {
|
||||||
|
if _, err := store.GameClientBridgeCommands().GetByIdempotency(lookup[0], lookup[1], lookup[2], lookup[3]); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("idempotency scope leaked for %v: %v", lookup, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retained, err := store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{CompletedBefore: now})
|
||||||
|
if err != nil || len(retained) != 1 {
|
||||||
|
t.Fatalf("completed retention query: len=%d err=%v", len(retained), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeSnapshotRepositoryOrderingLimitAndStreams(t *testing.T) {
|
||||||
|
store := NewMemoryStore()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, snapshot := range []domain.GameClientBridgeSnapshot{
|
||||||
|
{ID: "snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Sequence: 1, ObservedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)},
|
||||||
|
{ID: "snapshot-2", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Sequence: 2, ObservedAt: now, ExpiresAt: now.Add(time.Hour)},
|
||||||
|
{ID: "snapshot-expired", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "vehicles", StreamKey: "current", Sequence: 1, ObservedAt: now, ExpiresAt: now.Add(-time.Minute)},
|
||||||
|
} {
|
||||||
|
if err := store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||||
|
t.Fatalf("create snapshot: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items, err := store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", Type: "players", Limit: 1})
|
||||||
|
if err != nil || len(items) != 1 || items[0].Sequence != 2 {
|
||||||
|
t.Fatalf("newest snapshot query: %#v err=%v", items, err)
|
||||||
|
}
|
||||||
|
expired, err := store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ExpiresBefore: now})
|
||||||
|
if err != nil || len(expired) != 1 || expired[0].ID != "snapshot-expired" {
|
||||||
|
t.Fatalf("snapshot retention query: %#v err=%v", expired, err)
|
||||||
|
}
|
||||||
|
for _, stream := range []domain.GameClientBridgeSnapshotStream{
|
||||||
|
{ID: "stream-players", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 2},
|
||||||
|
{ID: "stream-vehicles", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "vehicles", StreamKey: "current", LatestSequence: 7},
|
||||||
|
} {
|
||||||
|
if err := store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||||
|
t.Fatalf("create stream: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streams, err := store.GameClientBridgeSnapshotStreams().List(domain.GameClientBridgeSnapshotStreamFilter{ServerInstanceID: "server-1"})
|
||||||
|
if err != nil || len(streams) != 2 || streams[0].LatestSequence == streams[1].LatestSequence {
|
||||||
|
t.Fatalf("independent stream sequences: %#v err=%v", streams, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeFileStorePersistsAndDeletes(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||||
|
store, err := NewFileStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new file store: %v", err)
|
||||||
|
}
|
||||||
|
command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", CommandType: "diagnostic.safe", RequesterID: "user-1", IdempotencyKey: "diag-1", State: domain.GameClientBridgeCommandClaimed, Claim: domain.GameClientBridgeClaim{FencingToken: 3}}
|
||||||
|
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ServerInstanceID: "server-1", Type: "health", StreamKey: "current", Sequence: 4}
|
||||||
|
stream := domain.GameClientBridgeSnapshotStream{ID: "stream-1", ServerInstanceID: "server-1", Type: "health", StreamKey: "current", LatestSequence: 4}
|
||||||
|
if err := store.GameClientBridgeCommands().Create(command); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restarted, err := NewFileStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("restart file store: %v", err)
|
||||||
|
}
|
||||||
|
if got, err := restarted.GameClientBridgeCommands().Get(command.ID); err != nil || got.Claim.FencingToken != 3 {
|
||||||
|
t.Fatalf("persisted command: %#v err=%v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := restarted.GameClientBridgeSnapshots().Get(snapshot.ID); err != nil || got.Sequence != 4 {
|
||||||
|
t.Fatalf("persisted snapshot: %#v err=%v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := restarted.GameClientBridgeSnapshotStreams().Get(stream.ID); err != nil || got.LatestSequence != 4 {
|
||||||
|
t.Fatalf("persisted stream: %#v err=%v", got, err)
|
||||||
|
}
|
||||||
|
if err := restarted.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
||||||
|
t.Fatalf("delete snapshot: %v", err)
|
||||||
|
}
|
||||||
|
reloaded, err := NewFileStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload file store: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := reloaded.GameClientBridgeSnapshots().Get(snapshot.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("deleted snapshot revived: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLSnapshotRoundTripIncludesGameClientBridge(t *testing.T) {
|
||||||
|
source := &MySQLStore{MemoryStore: NewMemoryStore()}
|
||||||
|
_ = source.MemoryStore.GameClientBridgeCommands().Create(domain.GameClientBridgeCommand{ID: "command-1"})
|
||||||
|
_ = source.MemoryStore.GameClientBridgeSnapshots().Create(domain.GameClientBridgeSnapshot{ID: "snapshot-1"})
|
||||||
|
_ = source.MemoryStore.GameClientBridgeSnapshotStreams().Create(domain.GameClientBridgeSnapshotStream{ID: "stream-1", LatestSequence: 8})
|
||||||
|
target := &MySQLStore{MemoryStore: NewMemoryStore()}
|
||||||
|
target.loadSnapshot(source.snapshot())
|
||||||
|
if _, err := target.MemoryStore.GameClientBridgeCommands().Get("command-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := target.MemoryStore.GameClientBridgeSnapshots().Get("snapshot-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stream, err := target.MemoryStore.GameClientBridgeSnapshotStreams().Get("stream-1"); err != nil || stream.LatestSequence != 8 {
|
||||||
|
t.Fatalf("stream round trip: %#v err=%v", stream, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,33 @@ func (store *MySQLStore) Backups() BackupRepository {
|
|||||||
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
|
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *MySQLStore) Alerts() AlertRepository {
|
||||||
|
return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository {
|
||||||
|
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *MySQLStore) AIConfigDiffs() AIConfigDiffRepository {
|
||||||
|
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *MySQLStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||||
|
return &persistentGameClientBridgeCommandRepository{
|
||||||
|
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||||
|
repository: store.MemoryStore.bridgeCommands,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *MySQLStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
|
||||||
|
return &persistentRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]{repository: store.MemoryStore.bridgeSnapshots, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
|
||||||
|
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
|
||||||
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) initialize() error {
|
func (store *MySQLStore) initialize() error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -228,6 +255,12 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
|||||||
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
||||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||||
|
Alerts: snapshotRepository(store.MemoryStore.alerts),
|
||||||
|
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||||
|
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||||
|
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||||
|
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||||
|
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,4 +288,10 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
|||||||
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
||||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||||
|
loadRepository(store.MemoryStore.alerts, snapshot.Alerts)
|
||||||
|
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||||
|
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||||
|
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||||
|
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||||
|
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,52 @@ type BackupRepository interface {
|
|||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlertRepository interface {
|
||||||
|
Create(domain.AlertRecord) error
|
||||||
|
Get(id string) (domain.AlertRecord, error)
|
||||||
|
List(domain.AlertFilter) ([]domain.AlertRecord, error)
|
||||||
|
Update(domain.AlertRecord) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginLifecycleRepository interface {
|
||||||
|
Create(domain.PluginLifecycleInstallation) error
|
||||||
|
Get(id string) (domain.PluginLifecycleInstallation, error)
|
||||||
|
List(domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
||||||
|
Update(domain.PluginLifecycleInstallation) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigDiffRepository interface {
|
||||||
|
Create(domain.AIConfigDiffPreview) error
|
||||||
|
Get(id string) (domain.AIConfigDiffPreview, error)
|
||||||
|
List(domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||||
|
Update(domain.AIConfigDiffPreview) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeCommandRepository interface {
|
||||||
|
Create(domain.GameClientBridgeCommand) error
|
||||||
|
Get(id string) (domain.GameClientBridgeCommand, error)
|
||||||
|
GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error)
|
||||||
|
List(domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
||||||
|
Update(domain.GameClientBridgeCommand) error
|
||||||
|
Delete(id string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotRepository interface {
|
||||||
|
Create(domain.GameClientBridgeSnapshot) error
|
||||||
|
Get(id string) (domain.GameClientBridgeSnapshot, error)
|
||||||
|
List(domain.GameClientBridgeSnapshotFilter) ([]domain.GameClientBridgeSnapshot, error)
|
||||||
|
Update(domain.GameClientBridgeSnapshot) error
|
||||||
|
Delete(id string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeSnapshotStreamRepository interface {
|
||||||
|
Create(domain.GameClientBridgeSnapshotStream) error
|
||||||
|
Get(id string) (domain.GameClientBridgeSnapshotStream, error)
|
||||||
|
List(domain.GameClientBridgeSnapshotStreamFilter) ([]domain.GameClientBridgeSnapshotStream, error)
|
||||||
|
Update(domain.GameClientBridgeSnapshotStream) error
|
||||||
|
Delete(id string) error
|
||||||
|
}
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
Users() UserRepository
|
Users() UserRepository
|
||||||
AuthSessions() AuthSessionRepository
|
AuthSessions() AuthSessionRepository
|
||||||
@@ -202,6 +248,12 @@ type Store interface {
|
|||||||
AuditEvents() AuditEventRepository
|
AuditEvents() AuditEventRepository
|
||||||
MetricSamples() MetricSampleRepository
|
MetricSamples() MetricSampleRepository
|
||||||
Backups() BackupRepository
|
Backups() BackupRepository
|
||||||
|
Alerts() AlertRepository
|
||||||
|
PluginLifecycles() PluginLifecycleRepository
|
||||||
|
AIConfigDiffs() AIConfigDiffRepository
|
||||||
|
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
||||||
|
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
|
||||||
|
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemoryStore struct {
|
type MemoryStore struct {
|
||||||
@@ -228,6 +280,12 @@ type MemoryStore struct {
|
|||||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||||
|
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
||||||
|
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||||
|
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||||
|
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||||
|
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||||
|
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMemoryStore() *MemoryStore {
|
func NewMemoryStore() *MemoryStore {
|
||||||
@@ -343,6 +401,28 @@ func NewMemoryStore() *MemoryStore {
|
|||||||
domain.CopyBackupRecord,
|
domain.CopyBackupRecord,
|
||||||
matchBackup,
|
matchBackup,
|
||||||
),
|
),
|
||||||
|
alerts: newMemoryRepository(
|
||||||
|
func(alert domain.AlertRecord) string { return alert.ID },
|
||||||
|
domain.CopyAlertRecord,
|
||||||
|
matchAlert,
|
||||||
|
),
|
||||||
|
pluginLifecycle: newMemoryRepository(
|
||||||
|
func(installation domain.PluginLifecycleInstallation) string { return installation.ID },
|
||||||
|
domain.CopyPluginLifecycleInstallation,
|
||||||
|
matchPluginLifecycle,
|
||||||
|
),
|
||||||
|
aiConfigDiffs: newMemoryRepository(
|
||||||
|
func(preview domain.AIConfigDiffPreview) string { return preview.ID },
|
||||||
|
domain.CopyAIConfigDiffPreview,
|
||||||
|
matchAIConfigDiff,
|
||||||
|
),
|
||||||
|
bridgeCommands: newMemoryGameClientBridgeCommandRepository(),
|
||||||
|
bridgeSnapshots: newMemoryGameClientBridgeSnapshotRepository(),
|
||||||
|
bridgeStreams: newMemoryRepository(
|
||||||
|
func(stream domain.GameClientBridgeSnapshotStream) string { return stream.ID },
|
||||||
|
domain.CopyGameClientBridgeSnapshotStream,
|
||||||
|
matchGameClientBridgeSnapshotStream,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +461,20 @@ func (store *MemoryStore) LogStreams() LogStreamRepository { return store.
|
|||||||
func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents }
|
func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents }
|
||||||
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
|
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
|
||||||
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
|
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
|
||||||
|
func (store *MemoryStore) Alerts() AlertRepository { return store.alerts }
|
||||||
|
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
|
||||||
|
return store.pluginLifecycle
|
||||||
|
}
|
||||||
|
func (store *MemoryStore) AIConfigDiffs() AIConfigDiffRepository { return store.aiConfigDiffs }
|
||||||
|
func (store *MemoryStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||||
|
return store.bridgeCommands
|
||||||
|
}
|
||||||
|
func (store *MemoryStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
|
||||||
|
return store.bridgeSnapshots
|
||||||
|
}
|
||||||
|
func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
|
||||||
|
return store.bridgeStreams
|
||||||
|
}
|
||||||
|
|
||||||
type memoryRepository[T any, F any] struct {
|
type memoryRepository[T any, F any] struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -632,3 +726,52 @@ func matchBackup(record domain.BackupRecord, filter domain.BackupFilter) bool {
|
|||||||
return (filter.ServerInstanceID == "" || record.ServerInstanceID == filter.ServerInstanceID) &&
|
return (filter.ServerInstanceID == "" || record.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
(filter.State == "" || record.State == filter.State)
|
(filter.State == "" || record.State == filter.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func matchAlert(alert domain.AlertRecord, filter domain.AlertFilter) bool {
|
||||||
|
return (filter.State == "" || alert.State == filter.State) &&
|
||||||
|
(filter.SourceKind == "" || alert.SourceKind == filter.SourceKind) &&
|
||||||
|
(filter.SourceID == "" || alert.SourceID == filter.SourceID) &&
|
||||||
|
(filter.Severity == "" || alert.Severity == filter.Severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchPluginLifecycle(installation domain.PluginLifecycleInstallation, filter domain.PluginLifecycleFilter) bool {
|
||||||
|
return (filter.PluginID == "" || installation.PluginID == filter.PluginID) &&
|
||||||
|
(filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.CurrentState == "" || installation.CurrentState == filter.CurrentState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchAIConfigDiff(preview domain.AIConfigDiffPreview, filter domain.AIConfigDiffFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || preview.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.PluginID == "" || preview.PluginID == filter.PluginID) &&
|
||||||
|
(filter.State == "" || preview.State == filter.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchGameClientBridgeCommand(command domain.GameClientBridgeCommand, filter domain.GameClientBridgeCommandFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || command.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.PluginID == "" || command.PluginID == filter.PluginID) &&
|
||||||
|
(filter.ProfileKey == "" || command.ProfileKey == filter.ProfileKey) &&
|
||||||
|
(filter.State == "" || command.State == filter.State) &&
|
||||||
|
(filter.RequesterID == "" || command.RequesterID == filter.RequesterID) &&
|
||||||
|
(filter.CommandType == "" || command.CommandType == filter.CommandType) &&
|
||||||
|
(filter.IdempotencyKey == "" || command.IdempotencyKey == filter.IdempotencyKey) &&
|
||||||
|
(filter.ExpiresBefore.IsZero() || !command.ExpiresAt.After(filter.ExpiresBefore)) &&
|
||||||
|
(filter.CompletedBefore.IsZero() || (!command.CompletedAt.IsZero() && !command.CompletedAt.After(filter.CompletedBefore)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchGameClientBridgeSnapshot(snapshot domain.GameClientBridgeSnapshot, filter domain.GameClientBridgeSnapshotFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || snapshot.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.PluginID == "" || snapshot.PluginID == filter.PluginID) &&
|
||||||
|
(filter.ProfileKey == "" || snapshot.ProfileKey == filter.ProfileKey) &&
|
||||||
|
(filter.Type == "" || snapshot.Type == filter.Type) &&
|
||||||
|
(filter.StreamKey == "" || snapshot.StreamKey == filter.StreamKey) &&
|
||||||
|
(filter.ObservedAfter.IsZero() || snapshot.ObservedAt.After(filter.ObservedAfter)) &&
|
||||||
|
(filter.ExpiresBefore.IsZero() || !snapshot.ExpiresAt.After(filter.ExpiresBefore))
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotStream, filter domain.GameClientBridgeSnapshotStreamFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || stream.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.PluginID == "" || stream.PluginID == filter.PluginID) &&
|
||||||
|
(filter.ProfileKey == "" || stream.ProfileKey == filter.ProfileKey) &&
|
||||||
|
(filter.Type == "" || stream.Type == filter.Type) &&
|
||||||
|
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
|
|||||||
LastReconciledAt: stamp,
|
LastReconciledAt: stamp,
|
||||||
ReconcileCount: 2,
|
ReconcileCount: 2,
|
||||||
ReconcileOutcome: "confirmed active attempt",
|
ReconcileOutcome: "confirmed active attempt",
|
||||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "name=approved\n", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("1", 64), MaxReadBytes: 64 * 1024},
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "name=approved\n", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("1", 64), MaxReadBytes: 64 * 1024, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"}},
|
||||||
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"},
|
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"},
|
||||||
}
|
}
|
||||||
if err := store.Jobs().Create(job); err != nil {
|
if err := store.Jobs().Create(job); err != nil {
|
||||||
@@ -194,9 +194,17 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get reloaded job by idempotency: %v", err)
|
t.Fatalf("get reloaded job by idempotency: %v", err)
|
||||||
}
|
}
|
||||||
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionResult.Content != "private-read" {
|
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionInput.Inputs["templateKey"] != "players.by-id" || gotJob.ExecutionInput.Inputs["playerId"] != "steam-123" || gotJob.ExecutionResult.Content != "private-read" {
|
||||||
t.Fatalf("unexpected reloaded job: %+v", gotJob)
|
t.Fatalf("unexpected reloaded job: %+v", gotJob)
|
||||||
}
|
}
|
||||||
|
gotJob.ExecutionInput.Inputs["playerId"] = "mutated"
|
||||||
|
againJob, err := reloaded.Jobs().GetByIdempotency("run-local", "idem-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get reloaded job again: %v", err)
|
||||||
|
}
|
||||||
|
if againJob.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
||||||
|
t.Fatalf("expected reloaded job inputs to be isolated, got %+v", againJob.ExecutionInput.Inputs)
|
||||||
|
}
|
||||||
gotPlugin, err := reloaded.GamePlugins().Get(plugin.ID)
|
gotPlugin, err := reloaded.GamePlugins().Get(plugin.ID)
|
||||||
if err != nil || len(gotPlugin.RuntimeProfiles.LifecycleProfiles) != 1 || gotPlugin.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
|
if err != nil || len(gotPlugin.RuntimeProfiles.LifecycleProfiles) != 1 || gotPlugin.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
|
||||||
t.Fatalf("unexpected reloaded runtime profiles: plugin=%+v err=%v", gotPlugin, err)
|
t.Fatalf("unexpected reloaded runtime profiles: plugin=%+v err=%v", gotPlugin, err)
|
||||||
@@ -240,7 +248,7 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
|||||||
Attempt: 2, QueueEligibleAt: stamp, LeaseTokenHash: strings.Repeat("e", 64), LeaseSessionGen: 4,
|
Attempt: 2, QueueEligibleAt: stamp, LeaseTokenHash: strings.Repeat("e", 64), LeaseSessionGen: 4,
|
||||||
LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: stamp,
|
LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: stamp,
|
||||||
LastReconciledAt: stamp, ReconcileCount: 3, ReconcileOutcome: "confirmed active attempt", CreatedAt: stamp, UpdatedAt: stamp,
|
LastReconciledAt: stamp, ReconcileCount: 3, ReconcileOutcome: "confirmed active attempt", CreatedAt: stamp, UpdatedAt: stamp,
|
||||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "mysql-approved", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("3", 64), MaxReadBytes: 64 * 1024},
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "mysql-approved", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("3", 64), MaxReadBytes: 64 * 1024, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-456"}},
|
||||||
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"},
|
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"},
|
||||||
}
|
}
|
||||||
if err := source.MemoryStore.Jobs().Create(job); err != nil {
|
if err := source.MemoryStore.Jobs().Create(job); err != nil {
|
||||||
@@ -257,9 +265,74 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
|||||||
target := &MySQLStore{MemoryStore: NewMemoryStore()}
|
target := &MySQLStore{MemoryStore: NewMemoryStore()}
|
||||||
target.loadSnapshot(snapshot)
|
target.loadSnapshot(snapshot)
|
||||||
got, err := target.MemoryStore.Jobs().Get(job.ID)
|
got, err := target.MemoryStore.Jobs().Get(job.ID)
|
||||||
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
|
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionInput.Inputs["templateKey"] != "players.by-id" || got.ExecutionInput.Inputs["playerId"] != "steam-456" || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
|
||||||
t.Fatalf("unexpected MySQL snapshot job: job=%+v err=%v", got, err)
|
t.Fatalf("unexpected MySQL snapshot job: job=%+v err=%v", got, err)
|
||||||
}
|
}
|
||||||
|
got.ExecutionInput.Inputs["playerId"] = "mutated"
|
||||||
|
again, err := target.MemoryStore.Jobs().Get(job.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get MySQL snapshot job again: %v", err)
|
||||||
|
}
|
||||||
|
if again.ExecutionInput.Inputs["playerId"] != "steam-456" {
|
||||||
|
t.Fatalf("expected MySQL snapshot job inputs to be isolated, got %+v", again.ExecutionInput.Inputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "production-operations.json")
|
||||||
|
store, err := NewFileStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create file store: %v", err)
|
||||||
|
}
|
||||||
|
stamp := time.Date(2026, 7, 18, 14, 0, 0, 0, time.UTC)
|
||||||
|
alert := domain.AlertRecord{
|
||||||
|
ID: "alert-capacity", SourceKind: "run-endpoint", SourceID: "run-1", RuleKey: "capacity.pressure",
|
||||||
|
Severity: domain.AlertSeverityWarning, State: domain.AlertStateAcknowledged, Title: "Capacity pressure",
|
||||||
|
Message: "endpoint capacity is temporarily under pressure", OccurrenceCount: 2, Retryable: true,
|
||||||
|
RetryAfterSeconds: 30, LastAuditEventID: "audit-1", LastSeenAt: stamp, AcknowledgedBy: "operator-1",
|
||||||
|
AcknowledgedAt: stamp, CreatedAt: stamp.Add(-time.Minute), UpdatedAt: stamp,
|
||||||
|
}
|
||||||
|
installation := domain.PluginLifecycleInstallation{
|
||||||
|
ID: "plugin-lifecycle-1", PluginID: "game.scum", ServerInstanceID: "server-1",
|
||||||
|
CurrentVersion: "1.0.0", TargetVersion: "2.0.0", PreviousVersion: "0.9.0",
|
||||||
|
DesiredState: domain.PluginLifecycleStateEnabled, CurrentState: domain.PluginLifecycleStateUpgrading,
|
||||||
|
LastOperation: domain.PluginLifecycleOperationUpgrade, Compatibility: "compatible",
|
||||||
|
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade", AlertID: alert.ID,
|
||||||
|
AuditEventID: "audit-2", IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
||||||
|
}
|
||||||
|
diff := domain.AIConfigDiffPreview{
|
||||||
|
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
|
||||||
|
ServerInstanceID: "server-1", PluginID: "game.scum", ProviderID: "ai.openai", Model: "gpt-4.1",
|
||||||
|
Key: "server.properties", ConfigVersion: 4, CurrentConfigChecksum: "sha256:" + strings.Repeat("a", 64),
|
||||||
|
ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch",
|
||||||
|
State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp,
|
||||||
|
}
|
||||||
|
if err := store.Alerts().Create(alert); err != nil {
|
||||||
|
t.Fatalf("create alert: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.PluginLifecycles().Create(installation); err != nil {
|
||||||
|
t.Fatalf("create plugin lifecycle: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AIConfigDiffs().Create(diff); err != nil {
|
||||||
|
t.Fatalf("create AI config diff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restarted, err := NewFileStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("restart file store: %v", err)
|
||||||
|
}
|
||||||
|
gotAlert, alertErr := restarted.Alerts().Get(alert.ID)
|
||||||
|
if alertErr != nil || gotAlert.State != alert.State || gotAlert.OccurrenceCount != 2 || gotAlert.LastAuditEventID != alert.LastAuditEventID {
|
||||||
|
t.Fatalf("unexpected durable alert: alert=%+v err=%v", gotAlert, alertErr)
|
||||||
|
}
|
||||||
|
gotInstallation, lifecycleErr := restarted.PluginLifecycles().Get(installation.ID)
|
||||||
|
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
|
||||||
|
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
|
||||||
|
}
|
||||||
|
gotDiff, diffErr := restarted.AIConfigDiffs().Get(diff.ID)
|
||||||
|
if diffErr != nil || gotDiff.State != diff.State || gotDiff.CurrentConfigChecksum != diff.CurrentConfigChecksum || gotDiff.ProposedConfig != diff.ProposedConfig {
|
||||||
|
t.Fatalf("unexpected durable AI config diff: diff=%+v err=%v", gotDiff, diffErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
"browser.local/platform/repo"
|
"browser.local/platform/repo"
|
||||||
@@ -45,7 +46,8 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
|||||||
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
||||||
return domain.AIInvocationResponse{}, err
|
return domain.AIInvocationResponse{}, err
|
||||||
}
|
}
|
||||||
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
return domain.AIInvocationResponse{}, err
|
return domain.AIInvocationResponse{}, err
|
||||||
}
|
}
|
||||||
if request.ServerInstanceID != "" {
|
if request.ServerInstanceID != "" {
|
||||||
@@ -56,6 +58,13 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
|||||||
if request.PluginID != "" && instance.PluginID != request.PluginID {
|
if request.PluginID != "" && instance.PluginID != request.PluginID {
|
||||||
return safeAIDenial(request, "plugin scope does not match server instance"), nil
|
return safeAIDenial(request, "plugin scope does not match server instance"), nil
|
||||||
}
|
}
|
||||||
|
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||||
|
config, err := svc.GetServerConfigForSession(sessionID, instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIInvocationResponse{}, err
|
||||||
|
}
|
||||||
|
request.CurrentConfig = config.Content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if request.PluginID != "" {
|
if request.PluginID != "" {
|
||||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||||
@@ -82,14 +91,24 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
|||||||
}
|
}
|
||||||
result, err := svc.aiProviderClient.Invoke(provider, request)
|
result, err := svc.aiProviderClient.Invoke(provider, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
auditID, auditErr := svc.recordAuditEventWithID(user.ID, "ai.provider.invoke.failed", "ai-provider", provider.ID, domain.AuditResultFailed, "AI provider invocation failed safely")
|
||||||
|
if auditErr != nil {
|
||||||
|
return domain.AIInvocationResponse{}, auditErr
|
||||||
|
}
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider invocation failed", Message: "AI provider invocation failed safely", Retryable: false, LastAuditEventID: auditID})
|
||||||
|
svc.productionMu.Unlock()
|
||||||
|
if alertErr != nil {
|
||||||
|
return domain.AIInvocationResponse{}, alertErr
|
||||||
|
}
|
||||||
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
||||||
RequestID: request.RequestID,
|
RequestID: request.RequestID,
|
||||||
Purpose: request.Purpose,
|
Purpose: request.Purpose,
|
||||||
ProviderID: provider.ID,
|
ProviderID: provider.ID,
|
||||||
Model: safeModel(request.Model, provider),
|
Model: safeModel(request.Model, provider),
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider), Mocked: true},
|
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider)},
|
||||||
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: safeBridgeReason(err.Error())},
|
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: "AI provider invocation failed safely"},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
response := domain.AIInvocationResponse{
|
response := domain.AIInvocationResponse{
|
||||||
@@ -102,7 +121,16 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
|||||||
Usage: result.Usage,
|
Usage: result.Usage,
|
||||||
}
|
}
|
||||||
if result.SuggestedConfig != "" {
|
if result.SuggestedConfig != "" {
|
||||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: "server.properties", SuggestedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch"}
|
if request.ServerInstanceID == "" {
|
||||||
|
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
|
||||||
|
}
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
|
||||||
|
svc.productionMu.Unlock()
|
||||||
|
if persistErr != nil {
|
||||||
|
return domain.AIInvocationResponse{}, persistErr
|
||||||
|
}
|
||||||
|
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: preview.Key, SuggestedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, DiffID: preview.ID, ExpiresAt: preview.ExpiresAt.Format(time.RFC3339)}
|
||||||
}
|
}
|
||||||
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
||||||
return domain.AIInvocationResponse{}, err
|
return domain.AIInvocationResponse{}, err
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxAIProviderResponseBytes = 1024 * 1024
|
||||||
|
|
||||||
|
type AIProviderSecretResolver interface {
|
||||||
|
Resolve(reference string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvironmentAIProviderSecretResolver struct{}
|
||||||
|
|
||||||
|
func (EnvironmentAIProviderSecretResolver) Resolve(reference string) (string, error) {
|
||||||
|
reference = strings.TrimSpace(reference)
|
||||||
|
if strings.HasPrefix(reference, "env://") {
|
||||||
|
name := strings.TrimPrefix(reference, "env://")
|
||||||
|
if !validEnvironmentName(name) {
|
||||||
|
return "", errors.New("AI provider secret reference is invalid")
|
||||||
|
}
|
||||||
|
if value := os.Getenv(name); value != "" {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("AI provider secret is unavailable")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(reference, "secret://providers/") {
|
||||||
|
name := strings.TrimPrefix(reference, "secret://providers/")
|
||||||
|
name = strings.ToUpper(regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(name, "_"))
|
||||||
|
name = strings.Trim(name, "_")
|
||||||
|
if name == "" {
|
||||||
|
return "", errors.New("AI provider secret reference is invalid")
|
||||||
|
}
|
||||||
|
if value := os.Getenv("PLATFORM_AI_PROVIDER_" + name + "_API_KEY"); value != "" {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("AI provider secret is unavailable")
|
||||||
|
}
|
||||||
|
return "", errors.New("AI provider secret backend is unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPAIProviderClient struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
SecretResolver AIProviderSecretResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []openAIChatMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message openAIChatMessage `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Messages []openAIChatMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeResponse struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
Usage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiRequest struct {
|
||||||
|
Contents []struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"contents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiResponse struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
UsageMetadata struct {
|
||||||
|
PromptTokenCount int `json:"promptTokenCount"`
|
||||||
|
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||||
|
} `json:"usageMetadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type structuredAIRecommendation struct {
|
||||||
|
Recommendation string `json:"recommendation"`
|
||||||
|
SuggestedConfig string `json:"suggestedConfig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client HTTPAIProviderClient) Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
|
||||||
|
model := safeModel(request.Model, provider)
|
||||||
|
endpoint, err := providerEndpoint(provider, model)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIProviderInvocationResult{}, err
|
||||||
|
}
|
||||||
|
secret := ""
|
||||||
|
if provider.RelayMode != domain.AIRelayModeLocal {
|
||||||
|
resolver := client.SecretResolver
|
||||||
|
if resolver == nil {
|
||||||
|
resolver = EnvironmentAIProviderSecretResolver{}
|
||||||
|
}
|
||||||
|
secret, err = resolver.Resolve(provider.APIKeyRef)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIProviderInvocationResult{}, errors.New("AI provider credential is unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompt := boundedProviderPrompt(request)
|
||||||
|
body, err := providerRequestBody(provider.Kind, model, prompt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIProviderInvocationResult{}, err
|
||||||
|
}
|
||||||
|
httpRequest, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIProviderInvocationResult{}, errors.New("AI provider request could not be created")
|
||||||
|
}
|
||||||
|
httpRequest.Header.Set("Content-Type", "application/json")
|
||||||
|
setProviderAuthorization(httpRequest, provider.Kind, secret)
|
||||||
|
httpClient := client.HTTPClient
|
||||||
|
if httpClient == nil {
|
||||||
|
timeout := time.Duration(provider.TimeoutMS) * time.Millisecond
|
||||||
|
if timeout <= 0 || timeout > 2*time.Minute {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
httpClient = &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
response, err := httpClient.Do(httpRequest)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIProviderInvocationResult{}, errors.New("AI provider transport failed")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return domain.AIProviderInvocationResult{}, fmt.Errorf("AI provider returned status class %dxx", response.StatusCode/100)
|
||||||
|
}
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(response.Body, maxAIProviderResponseBytes+1))
|
||||||
|
if err != nil || len(payload) > maxAIProviderResponseBytes {
|
||||||
|
return domain.AIProviderInvocationResult{}, errors.New("AI provider response was invalid or too large")
|
||||||
|
}
|
||||||
|
result, err := parseProviderResponse(provider.Kind, payload)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIProviderInvocationResult{}, errors.New("AI provider response was invalid")
|
||||||
|
}
|
||||||
|
result.Usage.ProviderID = provider.ID
|
||||||
|
result.Usage.Model = model
|
||||||
|
result.Usage.Mocked = false
|
||||||
|
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||||
|
result = parseStructuredRecommendation(result)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ConfigureAIProviderMode(mode string) error {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "mock", "test", "local":
|
||||||
|
svc.aiProviderClient = MockAIProviderClient{}
|
||||||
|
case "", "live", "http":
|
||||||
|
svc.aiProviderClient = HTTPAIProviderClient{SecretResolver: EnvironmentAIProviderSecretResolver{}}
|
||||||
|
default:
|
||||||
|
return validationError("AI provider mode must be live or mock")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerEndpoint(provider domain.AIProvider, model string) (string, error) {
|
||||||
|
base, err := url.Parse(strings.TrimRight(provider.BaseURL, "/"))
|
||||||
|
if err != nil || base.Host == "" {
|
||||||
|
return "", errors.New("AI provider endpoint is invalid")
|
||||||
|
}
|
||||||
|
host := base.Hostname()
|
||||||
|
if base.Scheme != "https" && !(base.Scheme == "http" && provider.RelayMode == domain.AIRelayModeLocal && isLoopbackHost(host)) {
|
||||||
|
return "", errors.New("AI provider endpoint requires HTTPS")
|
||||||
|
}
|
||||||
|
switch provider.Kind {
|
||||||
|
case domain.AIProviderKindClaude:
|
||||||
|
base.Path = strings.TrimRight(base.Path, "/") + "/messages"
|
||||||
|
case domain.AIProviderKindGemini:
|
||||||
|
base.Path = strings.TrimRight(base.Path, "/") + "/models/" + url.PathEscape(model) + ":generateContent"
|
||||||
|
default:
|
||||||
|
base.Path = strings.TrimRight(base.Path, "/") + "/chat/completions"
|
||||||
|
}
|
||||||
|
base.RawQuery = ""
|
||||||
|
base.Fragment = ""
|
||||||
|
return base.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerRequestBody(kind domain.AIProviderKind, model, prompt string) ([]byte, error) {
|
||||||
|
switch kind {
|
||||||
|
case domain.AIProviderKindClaude:
|
||||||
|
return json.Marshal(claudeRequest{Model: model, MaxTokens: 2048, Messages: []openAIChatMessage{{Role: "user", Content: prompt}}})
|
||||||
|
case domain.AIProviderKindGemini:
|
||||||
|
body := geminiRequest{}
|
||||||
|
body.Contents = append(body.Contents, struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
}{Parts: []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Text: prompt}}})
|
||||||
|
return json.Marshal(body)
|
||||||
|
default:
|
||||||
|
return json.Marshal(openAIChatRequest{Model: model, Messages: []openAIChatMessage{{Role: "user", Content: prompt}}, Temperature: 0.2})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProviderResponse(kind domain.AIProviderKind, payload []byte) (domain.AIProviderInvocationResult, error) {
|
||||||
|
switch kind {
|
||||||
|
case domain.AIProviderKindClaude:
|
||||||
|
var response claudeResponse
|
||||||
|
if err := json.Unmarshal(payload, &response); err != nil || len(response.Content) == 0 || strings.TrimSpace(response.Content[0].Text) == "" {
|
||||||
|
return domain.AIProviderInvocationResult{}, errors.New("invalid Claude response")
|
||||||
|
}
|
||||||
|
return domain.AIProviderInvocationResult{Recommendation: response.Content[0].Text, Usage: domain.AIInvocationUsage{InputTokens: response.Usage.InputTokens, OutputTokens: response.Usage.OutputTokens}}, nil
|
||||||
|
case domain.AIProviderKindGemini:
|
||||||
|
var response geminiResponse
|
||||||
|
if err := json.Unmarshal(payload, &response); err != nil || len(response.Candidates) == 0 || len(response.Candidates[0].Content.Parts) == 0 || strings.TrimSpace(response.Candidates[0].Content.Parts[0].Text) == "" {
|
||||||
|
return domain.AIProviderInvocationResult{}, errors.New("invalid Gemini response")
|
||||||
|
}
|
||||||
|
return domain.AIProviderInvocationResult{Recommendation: response.Candidates[0].Content.Parts[0].Text, Usage: domain.AIInvocationUsage{InputTokens: response.UsageMetadata.PromptTokenCount, OutputTokens: response.UsageMetadata.CandidatesTokenCount}}, nil
|
||||||
|
default:
|
||||||
|
var response openAIChatResponse
|
||||||
|
if err := json.Unmarshal(payload, &response); err != nil || len(response.Choices) == 0 || strings.TrimSpace(response.Choices[0].Message.Content) == "" {
|
||||||
|
return domain.AIProviderInvocationResult{}, errors.New("invalid chat completion response")
|
||||||
|
}
|
||||||
|
return domain.AIProviderInvocationResult{Recommendation: response.Choices[0].Message.Content, Usage: domain.AIInvocationUsage{InputTokens: response.Usage.PromptTokens, OutputTokens: response.Usage.CompletionTokens}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStructuredRecommendation(result domain.AIProviderInvocationResult) domain.AIProviderInvocationResult {
|
||||||
|
raw := strings.TrimSpace(result.Recommendation)
|
||||||
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
|
raw = strings.TrimPrefix(raw, "```")
|
||||||
|
raw = strings.TrimSuffix(raw, "```")
|
||||||
|
var structured structuredAIRecommendation
|
||||||
|
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &structured) == nil && strings.TrimSpace(structured.SuggestedConfig) != "" {
|
||||||
|
result.Recommendation = strings.TrimSpace(structured.Recommendation)
|
||||||
|
result.SuggestedConfig = structured.SuggestedConfig
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundedProviderPrompt(request domain.AIInvocationRequest) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString("Purpose: ")
|
||||||
|
builder.WriteString(request.Purpose)
|
||||||
|
builder.WriteString("\nRequest: ")
|
||||||
|
builder.WriteString(request.Prompt)
|
||||||
|
if request.CurrentConfig != "" {
|
||||||
|
builder.WriteString("\nCurrent configuration:\n")
|
||||||
|
builder.WriteString(request.CurrentConfig)
|
||||||
|
}
|
||||||
|
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||||
|
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes require separate operator approval.")
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setProviderAuthorization(request *http.Request, kind domain.AIProviderKind, secret string) {
|
||||||
|
if secret == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case domain.AIProviderKindClaude:
|
||||||
|
request.Header.Set("x-api-key", secret)
|
||||||
|
request.Header.Set("anthropic-version", "2023-06-01")
|
||||||
|
case domain.AIProviderKindGemini:
|
||||||
|
request.Header.Set("x-goog-api-key", secret)
|
||||||
|
default:
|
||||||
|
request.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEnvironmentName(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, char := range value {
|
||||||
|
if (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoopbackHost(host string) bool {
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTTPAIProviderClientReturnsBoundedStructuredRecommendation(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/v1/chat/completions" {
|
||||||
|
t.Fatalf("unexpected provider path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "" {
|
||||||
|
t.Fatal("local provider must not receive an authorization header")
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if strings.Contains(string(body), "apiKeyRef") {
|
||||||
|
t.Fatal("provider request leaked secret reference metadata")
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"{\"recommendation\":\"Review PVP policy\",\"suggestedConfig\":\"pvp=false\\n\"}"}}],"usage":{"prompt_tokens":12,"completion_tokens":8}}`))}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
client := HTTPAIProviderClient{HTTPClient: &http.Client{Transport: transport}}
|
||||||
|
result, err := client.Invoke(domain.AIProvider{ID: "local", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:18000/v1", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, DefaultModel: "test-model"}, domain.AIInvocationRequest{RequestID: "http-ai-1", Purpose: "config.suggest", Prompt: "disable pvp", CurrentConfig: "pvp=true\n"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invoke provider: %v", err)
|
||||||
|
}
|
||||||
|
if result.Recommendation != "Review PVP policy" || result.SuggestedConfig != "pvp=false\n" || result.Usage.Mocked {
|
||||||
|
t.Fatalf("unexpected provider result %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAIProviderClientRedactsTransportFailure(t *testing.T) {
|
||||||
|
client := HTTPAIProviderClient{HTTPClient: &http.Client{Transport: failingRoundTripper{}}}
|
||||||
|
_, err := client.Invoke(domain.AIProvider{ID: "local", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:18000/v1", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, DefaultModel: "test-model"}, domain.AIInvocationRequest{RequestID: "http-ai-fail", Purpose: "logs.diagnose", Prompt: "inspect"})
|
||||||
|
if err == nil || err.Error() != "AI provider transport failed" {
|
||||||
|
t.Fatalf("expected redacted transport failure, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingRoundTripper struct{}
|
||||||
|
|
||||||
|
func (failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||||
|
return fn(request)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientManagerLifecycleInputIncludesGeneratedCompanionConfig(t *testing.T) {
|
||||||
|
svc, ownerSession, instance := newDistributionTestFixture(t)
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get plugin: %v", err)
|
||||||
|
}
|
||||||
|
capabilities := []string{"component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"}
|
||||||
|
for index := range plugin.RuntimeProfiles.ClientManagers {
|
||||||
|
manager := &plugin.RuntimeProfiles.ClientManagers[index]
|
||||||
|
if manager.Key != "scum-client-manager" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
manager.ConfigTemplates = []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}}
|
||||||
|
manager.Health.IntervalSeconds = 30
|
||||||
|
manager.Health.RequiredCapabilities = domain.CopyStringSlice(capabilities)
|
||||||
|
}
|
||||||
|
plugin.GameClientBridge.Companion = domain.GameClientBridgeCompanionDeclaration{
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
ConfigTemplateKey: "client-config",
|
||||||
|
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||||
|
ConfigFormat: "yaml",
|
||||||
|
PlatformBaseURLSource: "run-control",
|
||||||
|
RegistrationProof: "hmac-sha256",
|
||||||
|
ProofMaterialSource: "component-package",
|
||||||
|
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||||
|
SessionMode: "component-session",
|
||||||
|
TLSPolicy: "verify-system-roots",
|
||||||
|
HeartbeatIntervalSeconds: 30,
|
||||||
|
CommandPollIntervalSeconds: 5,
|
||||||
|
RequestTimeoutSeconds: 15,
|
||||||
|
}
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("update companion declaration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "companion-config-build-v1")
|
||||||
|
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "companion-config-deploy-v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("queue companion deployment: %v", err)
|
||||||
|
}
|
||||||
|
runSession := registerClientManagerRun(t, svc)
|
||||||
|
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
|
||||||
|
request := domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
|
||||||
|
input, err := svc.GetClientManagerLifecycleInput(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get companion lifecycle input: %v", err)
|
||||||
|
}
|
||||||
|
config := input.CompanionConfig
|
||||||
|
if config == nil {
|
||||||
|
t.Fatal("expected generated companion config input")
|
||||||
|
}
|
||||||
|
if config.SchemaVersion != 1 || config.ConfigTemplateRef != "config.yaml.example" || config.ConfigOutputRef != "config.yaml" || config.ConfigSchemaRef != "schemas/companion/config.schema.json" {
|
||||||
|
t.Fatalf("unexpected companion template contract: %+v", config)
|
||||||
|
}
|
||||||
|
if config.InstallationID != view.Installation.ID || config.ServerInstanceID != instance.ID || config.PluginID != plugin.ID || config.ProfileKey != "scum-client-manager" || config.ArtifactID != distribution.ArtifactID || config.KeyGeneration != distribution.KeyGeneration || config.DeploymentGeneration != view.Installation.DeploymentGeneration {
|
||||||
|
t.Fatalf("unexpected companion identity fence: %+v", config)
|
||||||
|
}
|
||||||
|
if strings.Join(config.Capabilities, ",") != strings.Join(capabilities, ",") || config.PlatformBaseURLSource != "run-control" || config.RegistrationProof != "hmac-sha256" || config.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" || config.SessionMode != "component-session" || config.TLSPolicy != "verify-system-roots" {
|
||||||
|
t.Fatalf("unexpected companion registration policy: %+v", config)
|
||||||
|
}
|
||||||
|
if config.HeartbeatIntervalSeconds != 30 || config.CommandPollIntervalSeconds != 5 || config.RequestTimeoutSeconds != 15 {
|
||||||
|
t.Fatalf("unexpected companion timing policy: %+v", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin, err = svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload plugin: %v", err)
|
||||||
|
}
|
||||||
|
plugin.GameClientBridge.Companion.TLSPolicy = "skip-verification"
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("persist unsafe companion policy: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "security policy") {
|
||||||
|
t.Fatalf("expected unsafe persisted policy to fail closed, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.GameClientBridge.Companion.TLSPolicy = "verify-system-roots"
|
||||||
|
plugin.GameClientBridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("persist reserved proof environment: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "proofMaterialEnv") {
|
||||||
|
t.Fatalf("expected reserved proof environment to fail closed, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.GameClientBridge.Companion.ProofMaterialEnv = "SCUM_COMPONENT_PROOF"
|
||||||
|
for index := range plugin.RuntimeProfiles.ClientManagers {
|
||||||
|
plugin.RuntimeProfiles.ClientManagers[index].ConfigTemplates = nil
|
||||||
|
}
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("remove config template: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "config template") {
|
||||||
|
t.Fatalf("expected missing template to fail safely, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientManagerCompanionConfigInputIsOptionalForOtherProfiles(t *testing.T) {
|
||||||
|
config, err := clientManagerCompanionConfigInput(
|
||||||
|
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ProfileKey: "bridge-client"}}},
|
||||||
|
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
|
||||||
|
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
|
||||||
|
"artifact-1",
|
||||||
|
"1.0.0",
|
||||||
|
"revision-1",
|
||||||
|
)
|
||||||
|
if err != nil || config != nil {
|
||||||
|
t.Fatalf("expected no companion config for another profile, config=%+v err=%v", config, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err = clientManagerCompanionConfigInput(
|
||||||
|
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ConfigFormat: "yaml"}}},
|
||||||
|
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
|
||||||
|
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
|
||||||
|
"artifact-1",
|
||||||
|
"1.0.0",
|
||||||
|
"revision-1",
|
||||||
|
)
|
||||||
|
if err == nil || config != nil || !strings.Contains(err.Error(), "incomplete") {
|
||||||
|
t.Fatalf("expected partial companion declaration to fail closed, config=%+v err=%v", config, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -525,6 +525,64 @@ func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string
|
|||||||
return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound
|
return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clientManagerCompanionConfigInput(plugin domain.GamePlugin, profile domain.RuntimeClientManagerProfile, installation domain.ClientManagerInstallation, artifactID, version, revision string) (*domain.ClientManagerCompanionConfigInput, error) {
|
||||||
|
companion := plugin.GameClientBridge.Companion
|
||||||
|
if companion == (domain.GameClientBridgeCompanionDeclaration{}) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if companion.ProfileKey == "" {
|
||||||
|
return nil, validationError("client-manager companion declaration is incomplete")
|
||||||
|
}
|
||||||
|
if companion.ProfileKey != installation.ProfileKey {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var configTemplate domain.RuntimeConfigTemplate
|
||||||
|
found := false
|
||||||
|
for _, candidate := range profile.ConfigTemplates {
|
||||||
|
if candidate.Key == companion.ConfigTemplateKey {
|
||||||
|
configTemplate = candidate
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found || strings.TrimSpace(configTemplate.TemplateRef) == "" || configTemplate.OutputRef != "config.yaml" {
|
||||||
|
return nil, validationError("client-manager companion config template is unavailable")
|
||||||
|
}
|
||||||
|
config := domain.ClientManagerCompanionConfigInput{
|
||||||
|
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
|
||||||
|
ConfigTemplateKey: companion.ConfigTemplateKey,
|
||||||
|
ConfigTemplateRef: configTemplate.TemplateRef,
|
||||||
|
ConfigOutputRef: configTemplate.OutputRef,
|
||||||
|
ConfigSchemaRef: companion.ConfigSchemaRef,
|
||||||
|
ConfigFormat: companion.ConfigFormat,
|
||||||
|
PlatformBaseURLSource: companion.PlatformBaseURLSource,
|
||||||
|
InstallationID: installation.ID,
|
||||||
|
ServerInstanceID: installation.ServerInstanceID,
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
ProfileKey: installation.ProfileKey,
|
||||||
|
ArtifactID: artifactID,
|
||||||
|
Version: version,
|
||||||
|
SourceRevision: revision,
|
||||||
|
TargetOS: installation.TargetOS,
|
||||||
|
TargetArch: installation.TargetArch,
|
||||||
|
KeyGeneration: installation.KeyGeneration,
|
||||||
|
DeploymentGeneration: installation.DeploymentGeneration,
|
||||||
|
Capabilities: domain.CopyStringSlice(profile.Health.RequiredCapabilities),
|
||||||
|
RegistrationProof: companion.RegistrationProof,
|
||||||
|
ProofMaterialSource: companion.ProofMaterialSource,
|
||||||
|
ProofMaterialEnv: companion.ProofMaterialEnv,
|
||||||
|
SessionMode: companion.SessionMode,
|
||||||
|
TLSPolicy: companion.TLSPolicy,
|
||||||
|
HeartbeatIntervalSeconds: companion.HeartbeatIntervalSeconds,
|
||||||
|
CommandPollIntervalSeconds: companion.CommandPollIntervalSeconds,
|
||||||
|
RequestTimeoutSeconds: companion.RequestTimeoutSeconds,
|
||||||
|
}
|
||||||
|
if err := validator.ValidateClientManagerCompanionConfigInput(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool {
|
func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool {
|
||||||
for _, target := range profile.SupportedTargets {
|
for _, target := range profile.SupportedTargets {
|
||||||
if target.OS == targetOS && target.Arch == targetArch {
|
if target.OS == targetOS && target.Arch == targetArch {
|
||||||
@@ -755,7 +813,11 @@ func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientMana
|
|||||||
revision = installation.ActiveRevision
|
revision = installation.ActiveRevision
|
||||||
checksum = ""
|
checksum = ""
|
||||||
}
|
}
|
||||||
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey}), nil
|
companionConfig, err := clientManagerCompanionConfigInput(plugin, profile, installation, artifactID, version, revision)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ClientManagerLifecycleInput{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey, CompanionConfig: companionConfig}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
|
func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
|
||||||
|
|||||||
@@ -23,9 +23,17 @@ type dependencyResolution struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) GetDependencyCatalogForSession(sessionID, serverInstanceID string) (domain.DependencyCatalog, error) {
|
func (svc *CoreService) GetDependencyCatalogForSession(sessionID, serverInstanceID string) (domain.DependencyCatalog, error) {
|
||||||
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||||
|
if err != nil {
|
||||||
return domain.DependencyCatalog{}, err
|
return domain.DependencyCatalog{}, err
|
||||||
}
|
}
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DependencyCatalog{}, err
|
||||||
|
}
|
||||||
|
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
||||||
|
return domain.DependencyCatalog{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
|
||||||
|
}
|
||||||
resolution, err := svc.resolveDependencyContext(serverInstanceID)
|
resolution, err := svc.resolveDependencyContext(serverInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DependencyCatalog{}, err
|
return domain.DependencyCatalog{}, err
|
||||||
|
|||||||
@@ -76,6 +76,29 @@ func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDependencyCatalogNamesMissingPluginPermission(t *testing.T) {
|
||||||
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get plugin: %v", err)
|
||||||
|
}
|
||||||
|
permissions := plugin.DeclaredPermissions[:0]
|
||||||
|
for _, permission := range plugin.DeclaredPermissions {
|
||||||
|
if permission != "server.dependencies.manage" {
|
||||||
|
permissions = append(permissions, permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugin.DeclaredPermissions = permissions
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("update plugin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.GetDependencyCatalogForSession(session, instance.ID)
|
||||||
|
if !errors.Is(err, ErrForbidden) || !strings.Contains(err.Error(), "server.dependencies.manage") {
|
||||||
|
t.Fatalf("expected named dependency permission denial, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
|
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
|
||||||
svc, session, instance := newDistributionTestFixture(t)
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
|
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
|
||||||
|
dependencyPermissionDeclared := pluginDeclares(plugin, "server.dependencies.manage")
|
||||||
actions := domain.ServerRuntimeActions{
|
actions := domain.ServerRuntimeActions{
|
||||||
ServerInstanceID: instance.ID,
|
ServerInstanceID: instance.ID,
|
||||||
PluginID: plugin.ID,
|
PluginID: plugin.ID,
|
||||||
@@ -468,8 +469,8 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
|||||||
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
|
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
|
||||||
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
|
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
|
||||||
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
|
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
|
||||||
runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)),
|
runtimeAction("dependencies-check", "Check dependencies", dependencyPermissionDeclared && endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason))),
|
||||||
runtimeAction("dependencies-install", "Install dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)),
|
runtimeAction("dependencies-install", "Install dependencies", dependencyPermissionDeclared && endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason))),
|
||||||
runtimeAction("live-logs", "Live logs", pluginSupports(plugin, "logs.read"), "plugin does not declare live logs"),
|
runtimeAction("live-logs", "Live logs", pluginSupports(plugin, "logs.read"), "plugin does not declare live logs"),
|
||||||
runtimeAction("historical-logs", "Historical logs", endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason)),
|
runtimeAction("historical-logs", "Historical logs", endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason)),
|
||||||
},
|
},
|
||||||
@@ -608,7 +609,7 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d
|
|||||||
}
|
}
|
||||||
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
||||||
_ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: plugin permission is not declared")
|
_ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: plugin permission is not declared")
|
||||||
return domain.Job{}, ErrForbidden
|
return domain.Job{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
|
||||||
}
|
}
|
||||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil {
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil {
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
@@ -726,11 +727,11 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
|||||||
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
|
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
|
||||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
|
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
|
||||||
return ErrForbidden
|
return forbiddenError("plugin is not installed")
|
||||||
}
|
}
|
||||||
if !containsString(plugin.DeclaredPermissions, permission) {
|
if !containsString(plugin.DeclaredPermissions, permission) {
|
||||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin permission is not declared")
|
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin permission is not declared")
|
||||||
return ErrForbidden
|
return forbiddenError("plugin does not declare required permission: " + permission)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,42 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceRuntimeActionsGateDependenciesOnPluginPermission(t *testing.T) {
|
||||||
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get plugin: %v", err)
|
||||||
|
}
|
||||||
|
permissions := plugin.DeclaredPermissions[:0]
|
||||||
|
for _, permission := range plugin.DeclaredPermissions {
|
||||||
|
if permission != "server.dependencies.manage" {
|
||||||
|
permissions = append(permissions, permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugin.DeclaredPermissions = permissions
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("update plugin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actions, err := svc.GetServerRuntimeActionsForSession(session, instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get runtime actions: %v", err)
|
||||||
|
}
|
||||||
|
seen := 0
|
||||||
|
for _, action := range actions.Actions {
|
||||||
|
if action.Key != "dependencies-check" && action.Key != "dependencies-install" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen++
|
||||||
|
if action.Available || action.Reason != "plugin permission is not declared" {
|
||||||
|
t.Fatalf("expected dependency action gated by plugin permission, got %+v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen != 2 {
|
||||||
|
t.Fatalf("expected both dependency actions in %+v", actions.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUpload(t *testing.T) {
|
func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUpload(t *testing.T) {
|
||||||
svc, session, instance := newDistributionTestFixture(t)
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
|
|||||||
@@ -0,0 +1,642 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/repo"
|
||||||
|
"browser.local/platform/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultGameClientBridgeLeaseDuration = 60 * time.Second
|
||||||
|
|
||||||
|
const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
type gameClientBridgeComponentSession struct {
|
||||||
|
Session domain.ClientManagerSession
|
||||||
|
Installation domain.ClientManagerInstallation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||||
|
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
if !canAccessServer(user, instance) {
|
||||||
|
return domain.GameClientBridgeCommand{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if instance.PluginID != request.PluginID {
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command plugin must match server instance")
|
||||||
|
}
|
||||||
|
return svc.queueGameClientBridgeCommand(user.ID, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverInstanceID string) (domain.GameClientBridgeStatus, error) {
|
||||||
|
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
|
||||||
|
return domain.GameClientBridgeStatus{}, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeStatus{}, err
|
||||||
|
}
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeStatus{}, err
|
||||||
|
}
|
||||||
|
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}}
|
||||||
|
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeStatus{}, err
|
||||||
|
}
|
||||||
|
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||||
|
if !containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: profile.Key, Reason: "compatible companion session is offline", CommandTypes: gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands), SnapshotTypes: gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots), QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates)}
|
||||||
|
for _, installation := range installations {
|
||||||
|
if installation.ProfileKey != profile.Key || (installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded) || installation.RequiresRedeploy {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessions, listErr := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
|
||||||
|
if listErr != nil {
|
||||||
|
return domain.GameClientBridgeStatus{}, listErr
|
||||||
|
}
|
||||||
|
for _, session := range sessions {
|
||||||
|
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
|
||||||
|
declaration.Available = true
|
||||||
|
declaration.Reason = ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.Profiles = append(status.Profiles, declaration)
|
||||||
|
status.Available = status.Available || declaration.Available
|
||||||
|
}
|
||||||
|
if len(status.Profiles) > 0 {
|
||||||
|
status.Reason = "no compatible companion session is online"
|
||||||
|
}
|
||||||
|
if status.Available {
|
||||||
|
status.Reason = ""
|
||||||
|
}
|
||||||
|
return domain.CopyGameClientBridgeStatus(status), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
|
||||||
|
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
filter.PluginID = instance.PluginID
|
||||||
|
commands, err := svc.store.GameClientBridgeCommands().List(filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]domain.GameClientBridgeCommand, len(commands))
|
||||||
|
for index, command := range commands {
|
||||||
|
result[index] = domain.CopyGameClientBridgeCommand(command)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) GetGameClientBridgeCommandForSession(sessionID, commandID string) (domain.GameClientBridgeCommand, error) {
|
||||||
|
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID string, query domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) {
|
||||||
|
if err := validator.ValidateGameClientBridgeSnapshotQuery(query); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(query.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if query.PluginID != instance.PluginID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
limit := query.Limit
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: query.ServerInstanceID, PluginID: query.PluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]domain.GameClientBridgeSnapshot, len(snapshots))
|
||||||
|
for index, snapshot := range snapshots {
|
||||||
|
result[index] = domain.CopyGameClientBridgeSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) {
|
||||||
|
profileDeclared := false
|
||||||
|
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||||
|
if profile.Key == profileKey && containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||||
|
profileDeclared = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !profileDeclared {
|
||||||
|
return domain.GameClientBridgeCommandDeclaration{}, false
|
||||||
|
}
|
||||||
|
for _, declaration := range plugin.GameClientBridge.Commands {
|
||||||
|
if declaration.Type == commandType {
|
||||||
|
return declaration, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.GameClientBridgeCommandDeclaration{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeCommandTypes(declarations []domain.GameClientBridgeCommandDeclaration) []string {
|
||||||
|
values := make([]string, len(declarations))
|
||||||
|
for index, declaration := range declarations {
|
||||||
|
values[index] = declaration.Type
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapshotDeclaration) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
values := make([]string, 0, len(declarations))
|
||||||
|
for _, declaration := range declarations {
|
||||||
|
if _, exists := seen[declaration.Type]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[declaration.Type] = struct{}{}
|
||||||
|
values = append(values, declaration.Type)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
|
||||||
|
values := make([]string, len(declarations))
|
||||||
|
for index, declaration := range declarations {
|
||||||
|
values[index] = declaration.Key
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||||
|
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
svc.bridgeMu.Lock()
|
||||||
|
defer svc.bridgeMu.Unlock()
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
declaration, declared := gameClientBridgeCommandDeclaration(plugin, request.ProfileKey, request.CommandType)
|
||||||
|
if !declared {
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command type is not declared for the profile")
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(request.Payload)
|
||||||
|
if err != nil || len(payload) > declaration.MaxPayloadBytes {
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command payload exceeds declaration")
|
||||||
|
}
|
||||||
|
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
|
||||||
|
if err == nil {
|
||||||
|
return domain.CopyGameClientBridgeCommand(existing), nil
|
||||||
|
}
|
||||||
|
if err != nil && err != repo.ErrNotFound {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
if !request.ExpiresAt.After(stamp) {
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expiresAt must be in the future")
|
||||||
|
}
|
||||||
|
approvalState := domain.GameClientBridgeApprovalNotRequired
|
||||||
|
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelOperator {
|
||||||
|
approvalState = domain.GameClientBridgeApprovalApproved
|
||||||
|
}
|
||||||
|
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||||
|
approvalState = domain.GameClientBridgeApprovalPending
|
||||||
|
if requester, requesterErr := svc.store.Users().Get(requesterID); requesterErr == nil && isPlatformAdmin(requester) {
|
||||||
|
approvalState = domain.GameClientBridgeApprovalApproved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svc.bridgeSeq++
|
||||||
|
command := domain.GameClientBridgeCommand{
|
||||||
|
ID: fmt.Sprintf("bridge-command-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
|
||||||
|
ServerInstanceID: request.ServerInstanceID,
|
||||||
|
PluginID: request.PluginID,
|
||||||
|
ProfileKey: request.ProfileKey,
|
||||||
|
CommandType: request.CommandType,
|
||||||
|
Payload: domain.CopyGameClientBridgePayload(request.Payload),
|
||||||
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
|
Priority: request.Priority,
|
||||||
|
State: domain.GameClientBridgeCommandPending,
|
||||||
|
ApprovalState: approvalState,
|
||||||
|
RequesterID: requesterID,
|
||||||
|
ExpiresAt: request.ExpiresAt,
|
||||||
|
CreatedAt: stamp,
|
||||||
|
UpdatedAt: stamp,
|
||||||
|
}
|
||||||
|
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, "queued declared game client bridge command")
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
command.AuditReferences = []string{auditID}
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Create(command); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridgeComponentSession, limit int) ([]domain.GameClientBridgeCommand, error) {
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit < 1 || limit > 50 {
|
||||||
|
return nil, validationError("bridge claim limit must be between 1 and 50")
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
svc.bridgeMu.Lock()
|
||||||
|
defer svc.bridgeMu.Unlock()
|
||||||
|
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, State: domain.GameClientBridgeCommandPending})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.SliceStable(commands, func(left, right int) bool {
|
||||||
|
if commands[left].Priority != commands[right].Priority {
|
||||||
|
return commands[left].Priority > commands[right].Priority
|
||||||
|
}
|
||||||
|
if !commands[left].CreatedAt.Equal(commands[right].CreatedAt) {
|
||||||
|
return commands[left].CreatedAt.Before(commands[right].CreatedAt)
|
||||||
|
}
|
||||||
|
return commands[left].ID < commands[right].ID
|
||||||
|
})
|
||||||
|
claimed := make([]domain.GameClientBridgeCommand, 0, limit)
|
||||||
|
for _, command := range commands {
|
||||||
|
if len(claimed) == limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fencingToken := command.Claim.FencingToken + 1
|
||||||
|
command.State = domain.GameClientBridgeCommandClaimed
|
||||||
|
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
|
||||||
|
command.UpdatedAt = stamp
|
||||||
|
auditID, auditErr := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.claim", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion claimed bridge command")
|
||||||
|
if auditErr != nil {
|
||||||
|
return nil, auditErr
|
||||||
|
}
|
||||||
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claimed = append(claimed, domain.CopyGameClientBridgeCommand(command))
|
||||||
|
}
|
||||||
|
return claimed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
svc.bridgeMu.Lock()
|
||||||
|
defer svc.bridgeMu.Unlock()
|
||||||
|
command, err := svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
command.Claim.AcknowledgedAt = stamp
|
||||||
|
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
|
||||||
|
command.UpdatedAt = stamp
|
||||||
|
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.ack", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion acknowledged bridge command")
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||||
|
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
svc.bridgeMu.Lock()
|
||||||
|
defer svc.bridgeMu.Unlock()
|
||||||
|
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||||
|
if command.Result.CompletedBy == component.Session.ID && gameClientBridgeClaimMatches(command, component, request.FencingToken) && command.Result.Status == request.Status && command.Result.Summary == request.Summary && reflect.DeepEqual(command.Result.Payload, request.Payload) {
|
||||||
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
|
}
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command already has a terminal result")
|
||||||
|
}
|
||||||
|
command, err = svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
switch request.Status {
|
||||||
|
case domain.GameClientBridgeResultSucceeded:
|
||||||
|
command.State = domain.GameClientBridgeCommandSucceeded
|
||||||
|
case domain.GameClientBridgeResultFailed:
|
||||||
|
command.State = domain.GameClientBridgeCommandFailed
|
||||||
|
case domain.GameClientBridgeResultCancelled:
|
||||||
|
command.State = domain.GameClientBridgeCommandCancelled
|
||||||
|
}
|
||||||
|
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
|
||||||
|
command.CompletedAt = stamp
|
||||||
|
command.UpdatedAt = stamp
|
||||||
|
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion recorded terminal bridge command result")
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
svc.bridgeMu.Lock()
|
||||||
|
defer svc.bridgeMu.Unlock()
|
||||||
|
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
if !canAccessServer(user, instance) {
|
||||||
|
return domain.GameClientBridgeCommand{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if !isTerminalGameClientBridgeCommandState(command.State) && !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||||
|
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
||||||
|
}
|
||||||
|
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||||
|
if command.State == domain.GameClientBridgeCommandCancelled {
|
||||||
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
|
}
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command is already terminal")
|
||||||
|
}
|
||||||
|
command.State = domain.GameClientBridgeCommandCancelled
|
||||||
|
command.Cancellation = domain.GameClientBridgeCancellation{RequestedBy: user.ID, Reason: request.Reason, CancelledAt: stamp}
|
||||||
|
command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp}
|
||||||
|
command.CompletedAt = stamp
|
||||||
|
command.UpdatedAt = stamp
|
||||||
|
auditID, err := svc.recordAuditEventWithID(user.ID, "game-client-bridge.command.cancel", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "operator cancelled bridge command")
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ReconcileGameClientBridgeCommands() error {
|
||||||
|
stamp := svc.now()
|
||||||
|
svc.bridgeMu.Lock()
|
||||||
|
defer svc.bridgeMu.Unlock()
|
||||||
|
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := svc.pruneGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return svc.pruneGameClientBridgeSnapshotsLocked(stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) pruneGameClientBridgeCommandsLocked(stamp time.Time) error {
|
||||||
|
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groups := map[string][]domain.GameClientBridgeCommand{}
|
||||||
|
for _, command := range commands {
|
||||||
|
if !isTerminalGameClientBridgeCommandState(command.State) || command.CompletedAt.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
retention := defaultGameClientBridgeCommandRetention
|
||||||
|
maxRecords := 0
|
||||||
|
if plugin, pluginErr := svc.store.GamePlugins().Get(command.PluginID); pluginErr == nil {
|
||||||
|
if plugin.GameClientBridge.Retention.KeepForSeconds > 0 {
|
||||||
|
retention = time.Duration(plugin.GameClientBridge.Retention.KeepForSeconds) * time.Second
|
||||||
|
}
|
||||||
|
maxRecords = plugin.GameClientBridge.Retention.MaxRecords
|
||||||
|
}
|
||||||
|
if !command.CompletedAt.After(stamp.Add(-retention)) {
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if maxRecords > 0 {
|
||||||
|
key := command.ServerInstanceID + "\x00" + command.PluginID
|
||||||
|
groups[key] = append(groups[key], command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range groups {
|
||||||
|
if len(group) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(group[0].PluginID)
|
||||||
|
if err != nil || plugin.GameClientBridge.Retention.MaxRecords <= 0 || len(group) <= plugin.GameClientBridge.Retention.MaxRecords {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort.SliceStable(group, func(left, right int) bool {
|
||||||
|
if !group[left].CompletedAt.Equal(group[right].CompletedAt) {
|
||||||
|
return group[left].CompletedAt.After(group[right].CompletedAt)
|
||||||
|
}
|
||||||
|
return group[left].ID < group[right].ID
|
||||||
|
})
|
||||||
|
for _, command := range group[plugin.GameClientBridge.Retention.MaxRecords:] {
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) pruneGameClientBridgeSnapshotsLocked(stamp time.Time) error {
|
||||||
|
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groups := map[string][]domain.GameClientBridgeSnapshot{}
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
if !snapshot.ExpiresAt.IsZero() && !snapshot.ExpiresAt.After(stamp) {
|
||||||
|
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := snapshot.ServerInstanceID + "\x00" + snapshot.PluginID + "\x00" + snapshot.ProfileKey + "\x00" + snapshot.Type + "\x00" + snapshot.StreamKey
|
||||||
|
groups[key] = append(groups[key], snapshot)
|
||||||
|
}
|
||||||
|
for _, group := range groups {
|
||||||
|
if len(group) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
maxRecords := group[0].Retention.MaxRecords
|
||||||
|
if plugin, pluginErr := svc.store.GamePlugins().Get(group[0].PluginID); pluginErr == nil {
|
||||||
|
if declaration, ok := gameClientBridgeSnapshotDeclaration(plugin, group[0].Type, group[0].SchemaVersion); ok {
|
||||||
|
maxRecords = declaration.Retention.MaxRecords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxRecords <= 0 || len(group) <= maxRecords {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort.SliceStable(group, func(left, right int) bool {
|
||||||
|
if group[left].Sequence != group[right].Sequence {
|
||||||
|
return group[left].Sequence > group[right].Sequence
|
||||||
|
}
|
||||||
|
return group[left].ID < group[right].ID
|
||||||
|
})
|
||||||
|
for _, snapshot := range group[maxRecords:] {
|
||||||
|
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) error {
|
||||||
|
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, command := range commands {
|
||||||
|
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||||
|
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if command.State == domain.GameClientBridgeCommandClaimed && !command.Claim.LeaseExpiresAt.IsZero() && !command.Claim.LeaseExpiresAt.After(stamp) {
|
||||||
|
fencingToken := command.Claim.FencingToken
|
||||||
|
command.State = domain.GameClientBridgeCommandPending
|
||||||
|
command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken}
|
||||||
|
command.UpdatedAt = stamp
|
||||||
|
auditID, auditErr := svc.recordAuditEventWithID("platform", "game-client-bridge.command.lease-expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "expired bridge claim returned to pending")
|
||||||
|
if auditErr != nil {
|
||||||
|
return auditErr
|
||||||
|
}
|
||||||
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) fencedGameClientBridgeCommand(component gameClientBridgeComponentSession, commandID string, fencingToken uint64, stamp time.Time) (domain.GameClientBridgeCommand, error) {
|
||||||
|
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
if command.State != domain.GameClientBridgeCommandClaimed || !gameClientBridgeClaimMatches(command, component, fencingToken) {
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command claim is stale")
|
||||||
|
}
|
||||||
|
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||||
|
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
||||||
|
}
|
||||||
|
if !command.Claim.LeaseExpiresAt.After(stamp) {
|
||||||
|
return domain.GameClientBridgeCommand{}, validationError("bridge command claim lease expired")
|
||||||
|
}
|
||||||
|
return command, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error {
|
||||||
|
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
command.State = domain.GameClientBridgeCommandExpired
|
||||||
|
command.CompletedAt = stamp
|
||||||
|
command.UpdatedAt = stamp
|
||||||
|
auditID, err := svc.recordAuditEventWithID("platform", "game-client-bridge.command.expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "bridge command expired before completion")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||||
|
return svc.store.GameClientBridgeCommands().Update(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeClaimLeaseExpiry(stamp, commandExpiry time.Time) time.Time {
|
||||||
|
leaseExpiry := stamp.Add(defaultGameClientBridgeLeaseDuration)
|
||||||
|
if !commandExpiry.IsZero() && commandExpiry.Before(leaseExpiry) {
|
||||||
|
return commandExpiry
|
||||||
|
}
|
||||||
|
return leaseExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, component gameClientBridgeComponentSession, fencingToken uint64) bool {
|
||||||
|
return command.Claim.SessionID == component.Session.ID && command.Claim.InstallationID == component.Installation.ID && command.Claim.DeploymentGeneration == component.Session.DeploymentGeneration && command.Claim.FencingToken == fencingToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
|
||||||
|
switch state {
|
||||||
|
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/repo"
|
||||||
|
"browser.local/platform/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gameClientBridgeCapability = "game-client.bridge"
|
||||||
|
|
||||||
|
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||||
|
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return svc.claimGameClientBridgeCommands(component, request.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) AckGameClientBridgeCommand(request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return svc.ackGameClientBridgeCommand(component, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) CompleteGameClientBridgeCommand(request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
|
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeCommand{}, err
|
||||||
|
}
|
||||||
|
return svc.completeGameClientBridgeCommand(component, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
|
||||||
|
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||||
|
if err := validator.ValidateGameClientBridgeSnapshotIngestRequest(request); err != nil {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
|
}
|
||||||
|
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
if request.ObservedAt.After(stamp.Add(5 * time.Minute)) {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, validationError("snapshot observedAt is too far in the future")
|
||||||
|
}
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(component.Installation.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
|
}
|
||||||
|
declaration, declared := gameClientBridgeSnapshotDeclaration(plugin, request.Type, request.SchemaVersion)
|
||||||
|
if !declared {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot type and schema version are not declared")
|
||||||
|
}
|
||||||
|
if request.Retention != declaration.Retention {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot retention does not match declaration")
|
||||||
|
}
|
||||||
|
svc.bridgeMu.Lock()
|
||||||
|
defer svc.bridgeMu.Unlock()
|
||||||
|
|
||||||
|
streamID := gameClientBridgeStreamID(component.Session.ServerInstanceID, component.Installation.PluginID, component.Session.ProfileKey, request.Type, request.StreamKey)
|
||||||
|
latestSequence := uint64(0)
|
||||||
|
stream, streamErr := svc.store.GameClientBridgeSnapshotStreams().Get(streamID)
|
||||||
|
if streamErr == nil {
|
||||||
|
latestSequence = stream.LatestSequence
|
||||||
|
} else if streamErr != repo.ErrNotFound {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, streamErr
|
||||||
|
}
|
||||||
|
existing, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, StreamKey: request.StreamKey})
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
|
}
|
||||||
|
for _, snapshot := range existing {
|
||||||
|
if snapshot.Sequence > latestSequence {
|
||||||
|
latestSequence = snapshot.Sequence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.Sequence <= latestSequence {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, validationError("snapshot sequence is stale")
|
||||||
|
}
|
||||||
|
svc.bridgeSeq++
|
||||||
|
snapshot := domain.GameClientBridgeSnapshot{
|
||||||
|
ID: fmt.Sprintf("bridge-snapshot-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
|
||||||
|
ServerInstanceID: component.Session.ServerInstanceID,
|
||||||
|
PluginID: component.Installation.PluginID,
|
||||||
|
ProfileKey: component.Session.ProfileKey,
|
||||||
|
Type: request.Type,
|
||||||
|
SchemaVersion: request.SchemaVersion,
|
||||||
|
StreamKey: request.StreamKey,
|
||||||
|
Sequence: request.Sequence,
|
||||||
|
SourceSessionID: component.Session.ID,
|
||||||
|
ObservedAt: request.ObservedAt,
|
||||||
|
Payload: domain.CopyGameClientBridgePayload(request.Payload),
|
||||||
|
Retention: request.Retention,
|
||||||
|
CreatedAt: stamp,
|
||||||
|
ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second),
|
||||||
|
}
|
||||||
|
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.snapshot.ingest", "game-client-bridge-snapshot", snapshot.ID, domain.AuditResultSuccess, "companion uploaded typed bridge snapshot")
|
||||||
|
if err != nil {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
|
}
|
||||||
|
snapshot.AuditReferences = []string{auditID}
|
||||||
|
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
|
}
|
||||||
|
stream = domain.GameClientBridgeSnapshotStream{ID: streamID, ServerInstanceID: snapshot.ServerInstanceID, PluginID: snapshot.PluginID, ProfileKey: snapshot.ProfileKey, Type: snapshot.Type, StreamKey: snapshot.StreamKey, LatestSequence: snapshot.Sequence, UpdatedAt: stamp}
|
||||||
|
if streamErr == repo.ErrNotFound {
|
||||||
|
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := svc.store.GameClientBridgeSnapshotStreams().Update(stream); err != nil {
|
||||||
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
|
||||||
|
for _, declaration := range plugin.GameClientBridge.Snapshots {
|
||||||
|
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
|
||||||
|
return declaration, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.GameClientBridgeSnapshotDeclaration{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) authorizeGameClientBridgeSession(sessionToken string) (gameClientBridgeComponentSession, error) {
|
||||||
|
presentedHash := tokenHash(sessionToken)
|
||||||
|
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{Status: domain.ClientManagerSessionActive})
|
||||||
|
if err != nil {
|
||||||
|
return gameClientBridgeComponentSession{}, err
|
||||||
|
}
|
||||||
|
var session domain.ClientManagerSession
|
||||||
|
for _, candidate := range sessions {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(presentedHash)) == 1 {
|
||||||
|
session = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
if session.ID == "" || !stamp.Before(session.ExpiresAt) {
|
||||||
|
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !containsString(session.Capabilities, gameClientBridgeCapability) {
|
||||||
|
return gameClientBridgeComponentSession{}, ErrForbidden
|
||||||
|
}
|
||||||
|
installation, err := svc.store.ClientManagerInstallations().Get(session.InstallationID)
|
||||||
|
if err != nil {
|
||||||
|
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||||
|
}
|
||||||
|
if installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded {
|
||||||
|
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||||
|
}
|
||||||
|
if installation.ServerInstanceID != session.ServerInstanceID || installation.ProfileKey != session.ProfileKey || installation.RunEndpointID != session.RunEndpointID || installation.ActiveArtifactID != session.ArtifactID || installation.KeyGeneration != session.KeyGeneration || installation.DeploymentGeneration != session.DeploymentGeneration || installation.RequiresRedeploy {
|
||||||
|
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||||
|
}
|
||||||
|
key, err := svc.activeComponentKey(session.ServerInstanceID, domain.DistributionComponentClientManager, session.ProfileKey)
|
||||||
|
if err != nil || key.Generation != session.KeyGeneration {
|
||||||
|
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||||
|
}
|
||||||
|
return gameClientBridgeComponentSession{Session: domain.CopyClientManagerSession(session), Installation: domain.CopyClientManagerInstallation(installation)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeStreamID(serverInstanceID, pluginID, profileKey, snapshotType, streamKey string) string {
|
||||||
|
digest := sha256.Sum256([]byte(serverInstanceID + "\x00" + pluginID + "\x00" + profileKey + "\x00" + snapshotType + "\x00" + streamKey))
|
||||||
|
return "bridge-stream-" + hex.EncodeToString(digest[:16])
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
|
||||||
|
t.Helper()
|
||||||
|
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
|
||||||
|
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||||
|
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
|
||||||
|
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.store.ClientManagerSessions().Create(session); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.store.EncryptedComponentKeys().Create(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return installation, session
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
const token = "component-session-token"
|
||||||
|
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||||
|
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authorized-1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token, Limit: 5})
|
||||||
|
if err != nil || len(claimed) != 1 || claimed[0].ID != command.ID || claimed[0].Claim.SessionID != session.ID {
|
||||||
|
t.Fatalf("authorized claim: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
|
||||||
|
t.Fatalf("authorized ack: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil {
|
||||||
|
t.Fatalf("authorized result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotRequest := domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}
|
||||||
|
snapshot, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest)
|
||||||
|
if err != nil || snapshot.SourceSessionID != session.ID || snapshot.Sequence != 1 {
|
||||||
|
t.Fatalf("authorized snapshot: %#v err=%v", snapshot, err)
|
||||||
|
}
|
||||||
|
if snapshot.SourceSessionID == token {
|
||||||
|
t.Fatal("raw component token persisted in snapshot")
|
||||||
|
}
|
||||||
|
higherSnapshotRequest := snapshotRequest
|
||||||
|
higherSnapshotRequest.Sequence = 2
|
||||||
|
higherSnapshot, err := svc.UploadGameClientBridgeSnapshot(higherSnapshotRequest)
|
||||||
|
if err != nil || higherSnapshot.Sequence != 2 {
|
||||||
|
t.Fatalf("higher snapshot sequence was not accepted: %#v err=%v", higherSnapshot, err)
|
||||||
|
}
|
||||||
|
equalSnapshotRequest := higherSnapshotRequest
|
||||||
|
if _, err := svc.UploadGameClientBridgeSnapshot(equalSnapshotRequest); err == nil {
|
||||||
|
t.Fatal("expected latest snapshot sequence to be rejected")
|
||||||
|
}
|
||||||
|
if _, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest); err == nil {
|
||||||
|
t.Fatal("expected lower snapshot sequence rejection")
|
||||||
|
}
|
||||||
|
currentSnapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current"})
|
||||||
|
if err != nil || len(currentSnapshots) != 2 {
|
||||||
|
t.Fatalf("stale snapshot attempts changed current stream records: %#v err=%v", currentSnapshots, err)
|
||||||
|
}
|
||||||
|
isolatedStreamRequest := snapshotRequest
|
||||||
|
isolatedStreamRequest.StreamKey = "secondary"
|
||||||
|
if snapshot, err := svc.UploadGameClientBridgeSnapshot(isolatedStreamRequest); err != nil || snapshot.Sequence != 1 {
|
||||||
|
t.Fatalf("independent snapshot stream did not start at sequence one: %#v err=%v", snapshot, err)
|
||||||
|
}
|
||||||
|
stream, err := svc.store.GameClientBridgeSnapshotStreams().Get(gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"))
|
||||||
|
if err != nil || stream.LatestSequence != 2 {
|
||||||
|
t.Fatalf("snapshot stream projection: %#v err=%v", stream, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
const firstToken = "component-session-token-one"
|
||||||
|
_, firstSession := seedGameClientBridgeComponentSession(t, svc, *clock, firstToken)
|
||||||
|
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "session-owner-1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: firstToken, Limit: 1})
|
||||||
|
if err != nil || len(claimed) != 1 {
|
||||||
|
t.Fatalf("first session claim: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondToken = "component-session-token-two"
|
||||||
|
secondSession := firstSession
|
||||||
|
secondSession.ID = "component-session-2"
|
||||||
|
secondSession.TokenHash = tokenHash(secondToken)
|
||||||
|
if err := svc.store.ClientManagerSessions().Create(secondSession); err != nil {
|
||||||
|
t.Fatalf("create second current session: %v", err)
|
||||||
|
}
|
||||||
|
request := domain.GameClientBridgeAckRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}
|
||||||
|
if _, err := svc.AckGameClientBridgeCommand(request); err == nil {
|
||||||
|
t.Fatal("expected second session ack to be rejected")
|
||||||
|
}
|
||||||
|
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||||
|
t.Fatal("expected second session result to be rejected")
|
||||||
|
}
|
||||||
|
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: firstToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
|
||||||
|
t.Fatalf("claim owner could not ack after rejected second session: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeComponentSessionRejectsMissingCapabilityAndStaleFences(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
const token = "component-session-token"
|
||||||
|
installation, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||||
|
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authz-1")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Capabilities = []string{"component.heartbeat"}
|
||||||
|
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
|
||||||
|
t.Fatalf("expected missing capability rejection, got %v", err)
|
||||||
|
}
|
||||||
|
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
|
||||||
|
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installation.DeploymentGeneration++
|
||||||
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Fatalf("expected deployment fence rejection, got %v", err)
|
||||||
|
}
|
||||||
|
installation.DeploymentGeneration = session.DeploymentGeneration
|
||||||
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installation.ActiveArtifactID = "artifact-2"
|
||||||
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Fatalf("expected active artifact fence rejection, got %v", err)
|
||||||
|
}
|
||||||
|
installation.ActiveArtifactID = session.ArtifactID
|
||||||
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installation.KeyGeneration++
|
||||||
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Fatalf("expected installation key-generation fence rejection, got %v", err)
|
||||||
|
}
|
||||||
|
installation.KeyGeneration = session.KeyGeneration
|
||||||
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := svc.store.EncryptedComponentKeys().Get("key-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key.Generation++
|
||||||
|
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Fatalf("expected active component key-generation rejection, got %v", err)
|
||||||
|
}
|
||||||
|
key.Generation = session.KeyGeneration
|
||||||
|
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.ExpiresAt = *clock
|
||||||
|
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"healthy": true}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}); !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Fatalf("expected expired session rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||||
|
store := repo.NewMemoryStore()
|
||||||
|
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||||
|
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||||
|
t.Fatalf("seed bridge plugin: %v", err)
|
||||||
|
}
|
||||||
|
svc := newCoreService(store, func() time.Time { return now })
|
||||||
|
return svc, &now
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest {
|
||||||
|
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeComponent() gameClientBridgeComponentSession {
|
||||||
|
return gameClientBridgeComponentSession{
|
||||||
|
Session: domain.ClientManagerSession{ID: "component-session-1", ServerInstanceID: "server-1", ProfileKey: "scum-client", DeploymentGeneration: 3},
|
||||||
|
Installation: domain.ClientManagerInstallation{ID: "installation-1", PluginID: "game.scum"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
request := bridgeQueueRequest(*clock, "announce-1")
|
||||||
|
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("queue bridge command: %v", err)
|
||||||
|
}
|
||||||
|
duplicate, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||||
|
if err != nil || duplicate.ID != command.ID {
|
||||||
|
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
|
||||||
|
}
|
||||||
|
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||||
|
if len(commands) != 1 || len(command.AuditReferences) != 1 {
|
||||||
|
t.Fatalf("expected one durable audited command: %#v", commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
component := bridgeComponent()
|
||||||
|
claimed, err := svc.claimGameClientBridgeCommands(component, 10)
|
||||||
|
if err != nil || len(claimed) != 1 || claimed[0].State != domain.GameClientBridgeCommandClaimed || claimed[0].Claim.FencingToken != 1 {
|
||||||
|
t.Fatalf("claim bridge command: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
initialLeaseExpiry := claimed[0].Claim.LeaseExpiresAt
|
||||||
|
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 2}); err == nil {
|
||||||
|
t.Fatal("expected stale fencing token rejection")
|
||||||
|
}
|
||||||
|
*clock = clock.Add(10 * time.Second)
|
||||||
|
acked, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1})
|
||||||
|
if err != nil || acked.Claim.AcknowledgedAt.IsZero() || !acked.Claim.LeaseExpiresAt.Equal(clock.Add(defaultGameClientBridgeLeaseDuration)) || !acked.Claim.LeaseExpiresAt.After(initialLeaseExpiry) {
|
||||||
|
t.Fatalf("ack bridge command: %#v err=%v", acked, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Payload: map[string]any{"sessionToken": "must-not-persist"}}); err == nil {
|
||||||
|
t.Fatal("expected unsafe result material to be rejected")
|
||||||
|
}
|
||||||
|
resultRequest := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered", Payload: map[string]any{"delivered": true}}
|
||||||
|
completed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
||||||
|
if err != nil || completed.State != domain.GameClientBridgeCommandSucceeded || completed.Result.Status != domain.GameClientBridgeResultSucceeded || completed.CompletedAt.IsZero() {
|
||||||
|
t.Fatalf("complete bridge command: %#v err=%v", completed, err)
|
||||||
|
}
|
||||||
|
if len(completed.AuditReferences) < 3 {
|
||||||
|
t.Fatalf("expected queue, claim, and result audit references: %#v", completed.AuditReferences)
|
||||||
|
}
|
||||||
|
auditReferenceCount := len(completed.AuditReferences)
|
||||||
|
replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
||||||
|
if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) || len(replayed.AuditReferences) != auditReferenceCount {
|
||||||
|
t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil {
|
||||||
|
t.Fatal("expected conflicting terminal result rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
request := bridgeQueueRequest(*clock, "scope-key")
|
||||||
|
first, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Payload = map[string]any{"message": "changed but same idempotency scope"}
|
||||||
|
reused, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||||
|
if err != nil || reused.ID != first.ID {
|
||||||
|
t.Fatalf("same service idempotency scope was not reused: first=%#v reused=%#v err=%v", first, reused, err)
|
||||||
|
}
|
||||||
|
otherRequester, err := svc.queueGameClientBridgeCommand("user-2", request)
|
||||||
|
if err != nil || otherRequester.ID == first.ID {
|
||||||
|
t.Fatalf("requester was omitted from idempotency scope: %#v err=%v", otherRequester, err)
|
||||||
|
}
|
||||||
|
request.IdempotencyKey = "scope-key-2"
|
||||||
|
otherKey, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||||
|
if err != nil || otherKey.ID == first.ID {
|
||||||
|
t.Fatalf("idempotency key was omitted from service scope: %#v err=%v", otherKey, err)
|
||||||
|
}
|
||||||
|
request.ServerInstanceID = "server-2"
|
||||||
|
request.IdempotencyKey = "scope-key"
|
||||||
|
otherServer, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||||
|
if err != nil || otherServer.ID == first.ID {
|
||||||
|
t.Fatalf("server was omitted from service idempotency scope: %#v err=%v", otherServer, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeLeaseReclaimAndExpiry(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "lease-1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
component := bridgeComponent()
|
||||||
|
first, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||||
|
if err != nil || len(first) != 1 {
|
||||||
|
t.Fatalf("first claim: %#v err=%v", first, err)
|
||||||
|
}
|
||||||
|
*clock = clock.Add(defaultGameClientBridgeLeaseDuration + time.Second)
|
||||||
|
second, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||||
|
if err != nil || len(second) != 1 || second[0].Claim.FencingToken != 2 {
|
||||||
|
t.Fatalf("reclaim expired lease: %#v err=%v", second, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||||
|
t.Fatal("expected old claim fencing rejection")
|
||||||
|
}
|
||||||
|
*clock = command.ExpiresAt.Add(time.Second)
|
||||||
|
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
|
||||||
|
t.Fatalf("reconcile expired command: %v", err)
|
||||||
|
}
|
||||||
|
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
|
if err != nil || expired.State != domain.GameClientBridgeCommandExpired {
|
||||||
|
t.Fatalf("expected expired command: %#v err=%v", expired, err)
|
||||||
|
}
|
||||||
|
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||||
|
if err != nil || len(claimed) != 0 {
|
||||||
|
t.Fatalf("expired command was claimable: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgePendingCommandExpiresBeforeFirstClaim(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
request := bridgeQueueRequest(*clock, "pending-expiry")
|
||||||
|
request.ExpiresAt = clock.Add(30 * time.Second)
|
||||||
|
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*clock = request.ExpiresAt
|
||||||
|
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
|
||||||
|
if err != nil || len(claimed) != 0 {
|
||||||
|
t.Fatalf("expired pending command was claimable: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
|
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 || len(expired.AuditReferences) != 2 {
|
||||||
|
t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
for _, key := range []string{"expired-lease-ack", "expired-lease-result"} {
|
||||||
|
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, key)); err != nil {
|
||||||
|
t.Fatalf("queue %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
component := bridgeComponent()
|
||||||
|
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||||
|
if err != nil || len(claimed) != 2 {
|
||||||
|
t.Fatalf("claim lease-expiry commands: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*clock = claimed[0].Claim.LeaseExpiresAt
|
||||||
|
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||||
|
t.Fatal("expected ack at claim lease expiry to be rejected")
|
||||||
|
}
|
||||||
|
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||||
|
t.Fatal("expected result at claim lease expiry to be rejected")
|
||||||
|
}
|
||||||
|
for _, command := range claimed {
|
||||||
|
protected, getErr := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
|
if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken || len(protected.AuditReferences) != 2 {
|
||||||
|
t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reclaimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||||
|
if err != nil || len(reclaimed) != 2 {
|
||||||
|
t.Fatalf("reclaim protected commands after lease sweep: %#v err=%v", reclaimed, err)
|
||||||
|
}
|
||||||
|
for _, command := range reclaimed {
|
||||||
|
if command.Claim.FencingToken != 2 {
|
||||||
|
t.Fatalf("reclaimed command did not advance fencing token: %#v", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
deadline := clock.Add(30 * time.Second)
|
||||||
|
commands := make([]domain.GameClientBridgeCommand, 0, 2)
|
||||||
|
for _, key := range []string{"deadline-ack", "deadline-result"} {
|
||||||
|
request := bridgeQueueRequest(*clock, key)
|
||||||
|
request.ExpiresAt = deadline
|
||||||
|
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("queue deadline command: %v", err)
|
||||||
|
}
|
||||||
|
commands = append(commands, command)
|
||||||
|
}
|
||||||
|
component := bridgeComponent()
|
||||||
|
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||||
|
if err != nil || len(claimed) != 2 {
|
||||||
|
t.Fatalf("claim deadline commands: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
for _, command := range claimed {
|
||||||
|
if !command.Claim.LeaseExpiresAt.Equal(deadline) {
|
||||||
|
t.Fatalf("claim lease exceeded command deadline: %#v", command.Claim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*clock = deadline
|
||||||
|
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||||
|
t.Fatal("expected ack at command deadline to be rejected")
|
||||||
|
}
|
||||||
|
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||||
|
t.Fatal("expected result at command deadline to be rejected")
|
||||||
|
}
|
||||||
|
for _, command := range commands {
|
||||||
|
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
|
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || len(expired.AuditReferences) < 3 {
|
||||||
|
t.Fatalf("deadline mutation did not persist audited expiry: %#v err=%v", expired, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeFailedResultIsPersisted(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "failed-result"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
component := bridgeComponent()
|
||||||
|
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||||
|
if err != nil || len(claimed) != 1 {
|
||||||
|
t.Fatalf("claim failed-result command: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
request := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "game window unavailable", Payload: map[string]any{"retryable": true}}
|
||||||
|
failed, err := svc.completeGameClientBridgeCommand(component, request)
|
||||||
|
if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() || len(failed.AuditReferences) != 3 {
|
||||||
|
t.Fatalf("record failed result: %#v err=%v", failed, err)
|
||||||
|
}
|
||||||
|
persisted, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
|
if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) || len(persisted.AuditReferences) != len(failed.AuditReferences) {
|
||||||
|
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
|
||||||
|
if err := svc.store.Users().Create(user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth, err := svc.issueAuthSession(user, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := bridgeQueueRequest(*clock, "cancel-deadline")
|
||||||
|
request.ExpiresAt = clock.Add(30 * time.Second)
|
||||||
|
command, err := svc.queueGameClientBridgeCommand(user.ID, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
*clock = request.ExpiresAt
|
||||||
|
if _, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "too late"}); err == nil {
|
||||||
|
t.Fatal("expected cancellation at command deadline to be rejected")
|
||||||
|
}
|
||||||
|
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
|
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Cancellation.RequestedBy != "" || len(expired.AuditReferences) != 2 {
|
||||||
|
t.Fatalf("deadline cancellation did not preserve audited expiry: %#v err=%v", expired, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
|
||||||
|
if err := svc.store.Users().Create(user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth, err := svc.issueAuthSession(user, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command, err := svc.queueGameClientBridgeCommand(user.ID, bridgeQueueRequest(*clock, "cancel-1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
component := bridgeComponent()
|
||||||
|
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||||
|
if err != nil || len(claimed) != 1 {
|
||||||
|
t.Fatalf("claim: %#v err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
cancelled, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
||||||
|
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID {
|
||||||
|
t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err)
|
||||||
|
}
|
||||||
|
auditReferenceCount := len(cancelled.AuditReferences)
|
||||||
|
repeated, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
||||||
|
if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) || len(repeated.AuditReferences) != auditReferenceCount {
|
||||||
|
t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err)
|
||||||
|
}
|
||||||
|
remaining, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||||
|
if err != nil || len(remaining) != 0 {
|
||||||
|
t.Fatalf("cancelled command remained claimable: %#v err=%v", remaining, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "late success"}); err == nil {
|
||||||
|
t.Fatal("expected late success after cancellation rejection")
|
||||||
|
}
|
||||||
|
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||||
|
t.Fatal("expected late ack after cancellation rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequence(t *testing.T) {
|
||||||
|
svc, clock := newGameClientBridgeService(t)
|
||||||
|
plugin, err := svc.store.GamePlugins().Get("game.scum")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}
|
||||||
|
plugin.GameClientBridge.Snapshots[0].Retention = domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldCommand := domain.GameClientBridgeCommand{ID: "old-command", ServerInstanceID: "server-1", PluginID: "game.scum", State: domain.GameClientBridgeCommandSucceeded, CompletedAt: clock.Add(-2 * time.Hour)}
|
||||||
|
if err := svc.store.GameClientBridgeCommands().Create(oldCommand); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for sequence := uint64(1); sequence <= 4; sequence++ {
|
||||||
|
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}, ExpiresAt: clock.Add(time.Hour)}
|
||||||
|
if sequence == 1 {
|
||||||
|
snapshot.ExpiresAt = clock.Add(-time.Second)
|
||||||
|
}
|
||||||
|
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream := domain.GameClientBridgeSnapshotStream{ID: gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 4}
|
||||||
|
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
|
||||||
|
t.Fatalf("reconcile bridge retention: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.store.GameClientBridgeCommands().Get(oldCommand.ID); err == nil {
|
||||||
|
t.Fatal("old terminal command was not pruned")
|
||||||
|
}
|
||||||
|
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", Type: "players"})
|
||||||
|
if err != nil || len(snapshots) != 2 || snapshots[0].Sequence != 4 || snapshots[1].Sequence != 3 {
|
||||||
|
t.Fatalf("snapshot retention projection: %#v err=%v", snapshots, err)
|
||||||
|
}
|
||||||
|
retainedStream, err := svc.store.GameClientBridgeSnapshotStreams().Get(stream.ID)
|
||||||
|
if err != nil || retainedStream.LatestSequence != 4 {
|
||||||
|
t.Fatalf("stream sequence was reset by retention: %#v err=%v", retainedStream, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -255,6 +255,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
|||||||
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
|
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
|
||||||
return domain.RunJobResultResult{}, err
|
return domain.RunJobResultResult{}, err
|
||||||
}
|
}
|
||||||
|
if err := svc.projectProductionOpsJobResult(job, stamp); err != nil {
|
||||||
|
return domain.RunJobResultResult{}, err
|
||||||
|
}
|
||||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +600,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
|||||||
State: job.State,
|
State: job.State,
|
||||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
|
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
|
||||||
ResultRef: job.ResultRef,
|
ResultRef: job.ResultRef,
|
||||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds},
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs)},
|
||||||
LeaseToken: leaseToken,
|
LeaseToken: leaseToken,
|
||||||
Attempt: job.Attempt,
|
Attempt: job.Attempt,
|
||||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||||
|
|||||||
@@ -0,0 +1,992 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/repo"
|
||||||
|
"browser.local/platform/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
capacityHeartbeatStaleAfter = 2 * time.Minute
|
||||||
|
capacityRetryAfterSeconds = 30
|
||||||
|
capacityLogBacklogLimit = 256
|
||||||
|
capacityArtifactBacklogLimit = 128
|
||||||
|
aiConfigDiffTTL = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (svc *CoreService) GetProductionCapacityForSession(sessionID string) (domain.ProductionCapacitySummary, error) {
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProductionCapacitySummary{}, err
|
||||||
|
}
|
||||||
|
endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProductionCapacitySummary{}, err
|
||||||
|
}
|
||||||
|
visibleEndpointIDs, err := svc.visibleEndpointIDs(user)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProductionCapacitySummary{}, err
|
||||||
|
}
|
||||||
|
alerts, err := svc.store.Alerts().List(domain.AlertFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProductionCapacitySummary{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := domain.ProductionCapacitySummary{GeneratedAt: svc.now()}
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if !isPlatformAdmin(user) {
|
||||||
|
if _, visible := visibleEndpointIDs[endpoint.ID]; !visible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
running, queued, err := svc.capacityJobCounts(endpoint.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProductionCapacitySummary{}, err
|
||||||
|
}
|
||||||
|
projection := svc.capacityProjection(endpoint, running, queued)
|
||||||
|
for _, alert := range alerts {
|
||||||
|
if alert.SourceKind == "run-endpoint" && alert.SourceID == endpoint.ID && alert.RuleKey == "capacity.pressure" && alert.State != domain.AlertStateResolved {
|
||||||
|
projection.LastAdmissionDecision = domain.CapacityAdmissionDeferred
|
||||||
|
projection.LastAdmissionReason = alert.Message
|
||||||
|
projection.LastAdmissionCheckedAt = alert.LastSeenAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.Endpoints = append(summary.Endpoints, projection)
|
||||||
|
summary.TotalMaxJobs += projection.MaxJobs
|
||||||
|
summary.TotalRunningJobs += projection.RunningJobs
|
||||||
|
summary.TotalQueuedJobs += projection.QueuedJobs
|
||||||
|
}
|
||||||
|
for _, alert := range alerts {
|
||||||
|
if alert.State != domain.AlertStateResolved && svc.canAccessAlert(user, alert) {
|
||||||
|
summary.ActiveAlerts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(summary.Endpoints, func(i, j int) bool { return summary.Endpoints[i].RunEndpointID < summary.Endpoints[j].RunEndpointID })
|
||||||
|
return domain.CopyProductionCapacitySummary(summary), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) CheckCapacityAdmissionForSession(sessionID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
|
||||||
|
if err := validator.ValidateCapacityAdmissionRequest(request); err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
request, err = svc.authorizeCapacityRequest(user, request)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
defer svc.productionMu.Unlock()
|
||||||
|
return svc.checkCapacityAdmission(user.ID, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) checkCapacityAdmission(actorID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
|
||||||
|
endpoint, err := svc.store.RunEndpoints().Get(request.RunEndpointID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
running, queued, err := svc.capacityJobCounts(endpoint.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
projection := svc.capacityProjection(endpoint, running, queued)
|
||||||
|
decision := domain.CapacityAdmissionDecision{
|
||||||
|
Accepted: true, State: domain.CapacityAdmissionAccepted, Reason: "capacity available",
|
||||||
|
ServerInstanceID: request.ServerInstanceID, RunEndpointID: endpoint.ID, Capability: request.Capability,
|
||||||
|
TargetKey: request.TargetKey, MaxJobs: projection.MaxJobs, RunningJobs: projection.RunningJobs,
|
||||||
|
QueuedJobs: projection.QueuedJobs, CheckedAt: svc.now(),
|
||||||
|
}
|
||||||
|
pressure := append([]domain.CapacityPressureCode(nil), projection.PressureCodes...)
|
||||||
|
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{request.Capability})) > 0 {
|
||||||
|
pressure = appendCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
|
||||||
|
}
|
||||||
|
decision.PressureCodes = pressure
|
||||||
|
|
||||||
|
hardDenied := containsCapacityPressure(pressure, domain.CapacityPressureEndpointOffline) || containsCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
|
||||||
|
if hardDenied {
|
||||||
|
decision.Accepted = false
|
||||||
|
decision.State = domain.CapacityAdmissionDenied
|
||||||
|
decision.Reason = "endpoint is unavailable or missing the required capability"
|
||||||
|
} else if len(pressure) > 0 {
|
||||||
|
decision.Accepted = false
|
||||||
|
decision.State = domain.CapacityAdmissionDeferred
|
||||||
|
decision.Reason = "endpoint capacity is temporarily under pressure"
|
||||||
|
decision.RetryAfterSeconds = capacityRetryAfterSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
auditResult := domain.AuditResultSuccess
|
||||||
|
auditAction := "capacity.admission.accepted"
|
||||||
|
if !decision.Accepted {
|
||||||
|
auditResult = domain.AuditResultDenied
|
||||||
|
auditAction = "capacity.admission.denied"
|
||||||
|
}
|
||||||
|
auditID, err := svc.recordAuditEventWithID(actorID, auditAction, "run-endpoint", endpoint.ID, auditResult, decision.Reason)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
decision.AuditEventID = auditID
|
||||||
|
if !decision.Accepted {
|
||||||
|
severity := domain.AlertSeverityWarning
|
||||||
|
if hardDenied {
|
||||||
|
severity = domain.AlertSeverityCritical
|
||||||
|
}
|
||||||
|
alert, err := svc.upsertAlert(domain.AlertRecord{
|
||||||
|
SourceKind: "run-endpoint", SourceID: endpoint.ID, RuleKey: "capacity.pressure", Severity: severity,
|
||||||
|
Title: "Run endpoint capacity admission blocked", Message: decision.Reason, Retryable: true,
|
||||||
|
RetryAfterSeconds: decision.RetryAfterSeconds, LastAuditEventID: auditID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
decision.AlertID = alert.ID
|
||||||
|
} else if err := svc.resolveAlertForSource("run-endpoint", endpoint.ID, "capacity.pressure", actorID, "capacity returned to an admissible state", auditID); err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
if err := validator.ValidateCapacityAdmissionDecision(decision); err != nil {
|
||||||
|
return domain.CapacityAdmissionDecision{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyCapacityAdmissionDecision(decision), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ListAlertsForSession(sessionID string, filter domain.AlertFilter) ([]domain.AlertRecord, error) {
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
alerts, err := svc.store.Alerts().List(filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
visible := make([]domain.AlertRecord, 0, len(alerts))
|
||||||
|
for _, alert := range alerts {
|
||||||
|
if svc.canAccessAlert(user, alert) {
|
||||||
|
visible = append(visible, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
|
||||||
|
return domain.CopyAlertRecords(visible), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) AcknowledgeAlertForSession(sessionID string, request domain.AlertAcknowledgeRequest) (domain.AlertRecord, error) {
|
||||||
|
if err := validator.ValidateAlertAcknowledgeRequest(request); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
defer svc.productionMu.Unlock()
|
||||||
|
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
if !svc.canAccessAlert(user, alert) {
|
||||||
|
return domain.AlertRecord{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if alert.State == domain.AlertStateResolved {
|
||||||
|
return domain.AlertRecord{}, validationError("resolved alerts cannot be acknowledged")
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.acknowledge", "alert", alert.ID, domain.AuditResultSuccess, defaultAlertNote(request.Note, "alert acknowledged"))
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
alert.State = domain.AlertStateAcknowledged
|
||||||
|
alert.AcknowledgedBy = user.ID
|
||||||
|
alert.AcknowledgedAt = stamp
|
||||||
|
alert.LastAuditEventID = auditID
|
||||||
|
alert.UpdatedAt = stamp
|
||||||
|
if err := validator.ValidateAlertRecord(alert); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
if err := svc.store.Alerts().Update(alert); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyAlertRecord(alert), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ResolveAlertForSession(sessionID string, request domain.AlertResolveRequest) (domain.AlertRecord, error) {
|
||||||
|
if err := validator.ValidateAlertResolveRequest(request); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
defer svc.productionMu.Unlock()
|
||||||
|
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
if !svc.canAccessAlert(user, alert) {
|
||||||
|
return domain.AlertRecord{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if alert.State == domain.AlertStateResolved {
|
||||||
|
return domain.CopyAlertRecord(alert), nil
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
note := defaultAlertNote(request.Note, "alert resolved after operator review")
|
||||||
|
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.resolve", "alert", alert.ID, domain.AuditResultSuccess, note)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
alert.State = domain.AlertStateResolved
|
||||||
|
alert.ResolvedBy = user.ID
|
||||||
|
alert.ResolvedAt = stamp
|
||||||
|
alert.ResolutionNote = note
|
||||||
|
alert.LastAuditEventID = auditID
|
||||||
|
alert.UpdatedAt = stamp
|
||||||
|
if err := validator.ValidateAlertRecord(alert); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
if err := svc.store.Alerts().Update(alert); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyAlertRecord(alert), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) RetryAlertForSession(sessionID string, request domain.AlertRetryRequest) (domain.AlertRetryResult, error) {
|
||||||
|
if err := validator.ValidateAlertRetryRequest(request); err != nil {
|
||||||
|
return domain.AlertRetryResult{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRetryResult{}, err
|
||||||
|
}
|
||||||
|
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRetryResult{}, err
|
||||||
|
}
|
||||||
|
if !svc.canAccessAlert(user, alert) {
|
||||||
|
return domain.AlertRetryResult{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if !alert.Retryable {
|
||||||
|
return domain.AlertRetryResult{}, validationError("alert source is not retryable")
|
||||||
|
}
|
||||||
|
switch alert.SourceKind {
|
||||||
|
case "run-endpoint":
|
||||||
|
endpoint, err := svc.store.RunEndpoints().Get(alert.SourceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRetryResult{}, err
|
||||||
|
}
|
||||||
|
capability := firstCapacityCapability(endpoint.Capabilities)
|
||||||
|
decision, err := svc.CheckCapacityAdmissionForSession(sessionID, domain.CapacityAdmissionRequest{RunEndpointID: endpoint.ID, Capability: capability, IdempotencyKey: request.IdempotencyKey})
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRetryResult{}, err
|
||||||
|
}
|
||||||
|
updated, err := svc.store.Alerts().Get(alert.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRetryResult{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: updated, Decision: decision, Status: string(decision.State)}), nil
|
||||||
|
case "plugin-lifecycle":
|
||||||
|
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRetryResult{}, err
|
||||||
|
}
|
||||||
|
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, Operation: installation.LastOperation, TargetVersion: installation.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: true})
|
||||||
|
if err != nil {
|
||||||
|
return domain.AlertRetryResult{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: alert, Decision: result.Decision, Status: result.Status}), nil
|
||||||
|
default:
|
||||||
|
return domain.AlertRetryResult{}, validationError("alert source does not support scoped retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
installations, err := svc.store.PluginLifecycles().List(filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
visible := make([]domain.PluginLifecycleInstallation, 0, len(installations))
|
||||||
|
for _, installation := range installations {
|
||||||
|
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
||||||
|
if err == nil && canAccessServer(user, instance) {
|
||||||
|
visible = append(visible, installation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
|
||||||
|
return domain.CopyPluginLifecycleInstallations(visible), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error) {
|
||||||
|
if err := validator.ValidatePluginLifecycleRequest(request); err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
user, instance, err := svc.requireServerOwner(sessionID, request.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if instance.PluginID != request.PluginID {
|
||||||
|
return domain.PluginLifecycleResult{}, validationError("pluginId must match the server plugin")
|
||||||
|
}
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if !containsString(plugin.ProductionLifecycle.Operations, string(request.Operation)) {
|
||||||
|
return domain.PluginLifecycleResult{}, validationError("plugin lifecycle operation is not declared by the manifest")
|
||||||
|
}
|
||||||
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
capability, targetKey, err := pluginLifecycleDispatchMetadata(plugin, request.Operation)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if request.TargetVersion == "" {
|
||||||
|
request.TargetVersion = plugin.Version
|
||||||
|
}
|
||||||
|
if !containsString(plugin.SupportedOS, endpoint.Platform) {
|
||||||
|
return svc.pluginLifecycleDenied(user.ID, instance, plugin, request, "plugin is not compatible with the assigned endpoint platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
defer svc.productionMu.Unlock()
|
||||||
|
installationID := pluginLifecycleInstallationID(request.PluginID, request.ServerInstanceID)
|
||||||
|
installation, getErr := svc.store.PluginLifecycles().Get(installationID)
|
||||||
|
if errors.Is(getErr, repo.ErrNotFound) {
|
||||||
|
stamp := svc.now()
|
||||||
|
installation = domain.PluginLifecycleInstallation{ID: installationID, PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStatePending, Compatibility: "compatible", DependencyState: domain.DependencyStateUnknown, CreatedAt: stamp, UpdatedAt: stamp}
|
||||||
|
} else if getErr != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, getErr
|
||||||
|
}
|
||||||
|
if err := validatePluginLifecycleTransition(installation, request); err != nil {
|
||||||
|
return svc.pluginLifecycleDeniedLocked(user.ID, installation, request, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
existingJob, jobErr := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey)
|
||||||
|
if jobErr == nil {
|
||||||
|
if existingJob.ServerInstanceID != instance.ID || existingJob.Capability != capability || existingJob.TargetKey != targetKey || existingJob.ExecutionInput.PluginID != plugin.ID || existingJob.ExecutionInput.LifecycleOperation != string(request.Operation) || existingJob.ExecutionInput.TargetVersion != request.TargetVersion {
|
||||||
|
return domain.PluginLifecycleResult{}, validationError("idempotencyKey is already used for different plugin lifecycle inputs")
|
||||||
|
}
|
||||||
|
installation.JobID = existingJob.ID
|
||||||
|
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: existingJob, Status: "queued"}), nil
|
||||||
|
}
|
||||||
|
if !errors.Is(jobErr, repo.ErrNotFound) {
|
||||||
|
return domain.PluginLifecycleResult{}, jobErr
|
||||||
|
}
|
||||||
|
decision, err := svc.checkCapacityAdmission(user.ID, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey})
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if !decision.Accepted {
|
||||||
|
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Decision: decision, Status: string(decision.State)}), nil
|
||||||
|
}
|
||||||
|
job, err := svc.CreateJob(domain.Job{
|
||||||
|
ID: jobIDFromParts("job-plugin-lifecycle", installation.ID, request.IdempotencyKey), ServerInstanceID: instance.ID,
|
||||||
|
RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey,
|
||||||
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), PluginID: plugin.ID, LifecycleOperation: string(request.Operation), TargetVersion: request.TargetVersion},
|
||||||
|
Progress: domain.JobProgress{Percent: 0, Message: "plugin lifecycle operation queued"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
installation.PreviousVersion = installation.CurrentVersion
|
||||||
|
installation.TargetVersion = request.TargetVersion
|
||||||
|
installation.DesiredState = desiredPluginLifecycleState(request.Operation, installation)
|
||||||
|
installation.CurrentState = dispatchedPluginLifecycleState(request.Operation, installation.CurrentState)
|
||||||
|
installation.LastOperation = request.Operation
|
||||||
|
installation.JobID = job.ID
|
||||||
|
installation.IdempotencyKey = request.IdempotencyKey
|
||||||
|
installation.FailureReason = ""
|
||||||
|
installation.UpdatedAt = stamp
|
||||||
|
auditID, err := svc.recordAuditEventWithID(user.ID, "plugin.lifecycle."+string(request.Operation), "plugin-lifecycle", installation.ID, domain.AuditResultQueued, "plugin lifecycle operation admitted and queued")
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
installation.AuditEventID = auditID
|
||||||
|
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if errors.Is(getErr, repo.ErrNotFound) {
|
||||||
|
err = svc.store.PluginLifecycles().Create(installation)
|
||||||
|
} else {
|
||||||
|
err = svc.store.PluginLifecycles().Update(installation)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Decision: decision, Status: "queued"}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) {
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
previews, err := svc.store.AIConfigDiffs().List(filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
visible := make([]domain.AIConfigDiffPreview, 0, len(previews))
|
||||||
|
for _, preview := range previews {
|
||||||
|
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||||
|
if err == nil && canAccessServer(user, instance) {
|
||||||
|
visible = append(visible, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(visible, func(i, j int) bool { return visible[i].CreatedAt.After(visible[j].CreatedAt) })
|
||||||
|
return domain.CopyAIConfigDiffPreviews(visible), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error) {
|
||||||
|
if err := validator.ValidateAIConfigDiffApprovalRequest(request); err != nil {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
|
}
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
defer svc.productionMu.Unlock()
|
||||||
|
preview, err := svc.store.AIConfigDiffs().Get(request.DiffID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
|
}
|
||||||
|
if !canAccessServer(user, instance) || (!isPlatformAdmin(user) && preview.CreatedBy != user.ID) {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if preview.State == domain.AIConfigDiffStateApproved {
|
||||||
|
if preview.ApprovalIdempotencyKey != request.IdempotencyKey {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is already approved with another idempotency key")
|
||||||
|
}
|
||||||
|
job, err := svc.store.Jobs().Get(preview.JobID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
|
}
|
||||||
|
dispatch := domain.ServerConfigWriteDispatch{Job: job, Status: "queued"}
|
||||||
|
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
||||||
|
}
|
||||||
|
if preview.State != domain.AIConfigDiffStatePending {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is not pending approval")
|
||||||
|
}
|
||||||
|
if !preview.ExpiresAt.After(svc.now()) {
|
||||||
|
preview.State = domain.AIConfigDiffStateExpired
|
||||||
|
preview.UpdatedAt = svc.now()
|
||||||
|
_ = svc.store.AIConfigDiffs().Update(preview)
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff has expired")
|
||||||
|
}
|
||||||
|
dispatch, err := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{ServerInstanceID: preview.ServerInstanceID, ExpectedConfigVersion: preview.ConfigVersion, ExpectedChecksum: preview.CurrentConfigChecksum, Key: preview.Key, ProposedContent: preview.ProposedConfig, IdempotencyKey: request.IdempotencyKey})
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
preview.State = domain.AIConfigDiffStateApproved
|
||||||
|
preview.ApprovedBy = user.ID
|
||||||
|
preview.ApprovedAt = stamp
|
||||||
|
preview.ApprovalIdempotencyKey = request.IdempotencyKey
|
||||||
|
preview.JobID = dispatch.Job.ID
|
||||||
|
preview.UpdatedAt = stamp
|
||||||
|
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := svc.recordAuditEventWithID(user.ID, "ai.config-diff.approve", "ai-config-diff", preview.ID, domain.AuditResultQueued, "approved reviewed AI config diff and queued one config write job"); err != nil {
|
||||||
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time.Time) error {
|
||||||
|
if job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
defer svc.productionMu.Unlock()
|
||||||
|
installation, err := svc.store.PluginLifecycles().Get(pluginLifecycleInstallationID(job.ExecutionInput.PluginID, job.ServerInstanceID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if installation.JobID != job.ID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if job.State == domain.JobStateSucceeded {
|
||||||
|
applyPluginLifecycleSuccess(&installation)
|
||||||
|
installation.FailureReason = ""
|
||||||
|
if err := svc.resolveAlertForSource("plugin-lifecycle", installation.ID, "plugin.lifecycle.failed", "run:"+job.RunEndpointID, "plugin lifecycle job completed", installation.AuditEventID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
installation.CurrentState = domain.PluginLifecycleStateFailed
|
||||||
|
installation.FailureReason = "plugin lifecycle job did not complete successfully"
|
||||||
|
alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.failed", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle operation failed", Message: installation.FailureReason, Retryable: true, LastJobID: job.ID, LastAuditEventID: installation.AuditEventID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
installation.AlertID = alert.ID
|
||||||
|
}
|
||||||
|
installation.UpdatedAt = stamp
|
||||||
|
return svc.store.PluginLifecycles().Update(installation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) persistAIConfigDiff(actorID string, provider domain.AIProvider, request domain.AIInvocationRequest, result domain.AIProviderInvocationResult) (domain.AIConfigDiffPreview, error) {
|
||||||
|
config, err := svc.getServerConfigForUser(actorID, request.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AIConfigDiffPreview{}, err
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
preview := domain.AIConfigDiffPreview{ID: aiConfigDiffID(request.RequestID, request.ServerInstanceID), RequestID: request.RequestID, CreatedBy: actorID, ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, ProviderID: provider.ID, Model: result.Usage.Model, Key: config.Key, ConfigVersion: config.ConfigVersion, CurrentConfigChecksum: config.Checksum, ProposedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch", State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(aiConfigDiffTTL), CreatedAt: stamp, UpdatedAt: stamp}
|
||||||
|
if err := validator.ValidateAIConfigDiffPreview(preview); err != nil {
|
||||||
|
return domain.AIConfigDiffPreview{}, err
|
||||||
|
}
|
||||||
|
if err := svc.store.AIConfigDiffs().Create(preview); err != nil {
|
||||||
|
if !errors.Is(err, repo.ErrDuplicate) {
|
||||||
|
return domain.AIConfigDiffPreview{}, err
|
||||||
|
}
|
||||||
|
existing, getErr := svc.store.AIConfigDiffs().Get(preview.ID)
|
||||||
|
if getErr != nil {
|
||||||
|
return domain.AIConfigDiffPreview{}, getErr
|
||||||
|
}
|
||||||
|
if existing.CreatedBy != actorID || existing.ServerInstanceID != request.ServerInstanceID || existing.PluginID != request.PluginID || existing.ProposedConfig != result.SuggestedConfig {
|
||||||
|
return domain.AIConfigDiffPreview{}, validationError("requestId is already used for a different AI config recommendation")
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
return preview, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string) (domain.ServerConfig, error) {
|
||||||
|
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerConfig{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.store.Users().Get(userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerConfig{}, err
|
||||||
|
}
|
||||||
|
if !canAccessServer(user, instance) {
|
||||||
|
return domain.ServerConfig{}, ErrForbidden
|
||||||
|
}
|
||||||
|
config := domain.ServerConfig{ServerInstanceID: instance.ID, ConfigVersion: instance.ConfigVersion, Format: "properties", Key: instance.ConfigKey, Content: instance.ConfigContent, Checksum: instance.ConfigChecksum, Source: "platform-derived", UpdatedAt: instance.ConfigUpdatedAt}
|
||||||
|
if config.ConfigVersion <= 0 {
|
||||||
|
config.ConfigVersion = 1
|
||||||
|
}
|
||||||
|
if config.Key == "" {
|
||||||
|
config.Key = "server.properties"
|
||||||
|
}
|
||||||
|
if config.Content == "" {
|
||||||
|
config.Content = buildLogicalServerConfig(instance)
|
||||||
|
}
|
||||||
|
if config.Checksum == "" {
|
||||||
|
config.Checksum = validator.BytesChecksum([]byte(config.Content))
|
||||||
|
}
|
||||||
|
if config.UpdatedAt.IsZero() {
|
||||||
|
config.UpdatedAt = svc.now()
|
||||||
|
}
|
||||||
|
return config, validator.ValidateServerConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) authorizeCapacityRequest(user domain.User, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionRequest, error) {
|
||||||
|
if request.ServerInstanceID != "" {
|
||||||
|
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return request, err
|
||||||
|
}
|
||||||
|
if !canAccessServer(user, instance) {
|
||||||
|
return request, ErrForbidden
|
||||||
|
}
|
||||||
|
if request.RunEndpointID != "" && request.RunEndpointID != instance.RunEndpointID {
|
||||||
|
return request, validationError("runEndpointId must match server instance")
|
||||||
|
}
|
||||||
|
request.RunEndpointID = instance.RunEndpointID
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
if request.RunEndpointID == "" {
|
||||||
|
return request, validationError("serverInstanceId or runEndpointId is required")
|
||||||
|
}
|
||||||
|
if isPlatformAdmin(user) {
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
visible, err := svc.visibleEndpointIDs(user)
|
||||||
|
if err != nil {
|
||||||
|
return request, err
|
||||||
|
}
|
||||||
|
if _, ok := visible[request.RunEndpointID]; !ok {
|
||||||
|
return request, ErrForbidden
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) visibleEndpointIDs(user domain.User) (map[string]struct{}, error) {
|
||||||
|
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := map[string]struct{}{}
|
||||||
|
for _, instance := range instances {
|
||||||
|
if isPlatformAdmin(user) || canAccessServer(user, instance) {
|
||||||
|
ids[instance.RunEndpointID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) capacityJobCounts(endpointID string) (int, int, error) {
|
||||||
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpointID})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
running, queued := 0, 0
|
||||||
|
for _, job := range jobs {
|
||||||
|
switch job.State {
|
||||||
|
case domain.JobStateAccepted, domain.JobStateRunning:
|
||||||
|
running++
|
||||||
|
case domain.JobStateQueued, domain.JobStateRetrying:
|
||||||
|
queued++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return running, queued, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) capacityProjection(endpoint domain.RunEndpoint, durableRunning, durableQueued int) domain.EndpointCapacityProjection {
|
||||||
|
running := maxInt(endpoint.Capacity.RunningJobs, durableRunning)
|
||||||
|
queued := maxInt(endpoint.Capacity.QueuedJobs, durableQueued)
|
||||||
|
projection := domain.EndpointCapacityProjection{RunEndpointID: endpoint.ID, DisplayName: endpoint.DisplayName, Status: endpoint.Status, Capabilities: endpoint.Capabilities, MaxJobs: endpoint.Capacity.MaxJobs, RunningJobs: running, QueuedJobs: queued, LogBacklogBatches: endpoint.Capacity.LogBacklogBatches, ArtifactBacklogChunks: endpoint.Capacity.ArtifactBacklogChunks, Summary: safeBridgeReason(endpoint.Capacity.Summary), LastHeartbeatAt: endpoint.LastHeartbeatAt}
|
||||||
|
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
||||||
|
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointOffline)
|
||||||
|
}
|
||||||
|
if endpoint.LastHeartbeatAt.IsZero() || svc.now().Sub(endpoint.LastHeartbeatAt) > capacityHeartbeatStaleAfter {
|
||||||
|
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointStale)
|
||||||
|
}
|
||||||
|
if projection.MaxJobs <= 0 || running >= projection.MaxJobs {
|
||||||
|
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureJobLimit)
|
||||||
|
}
|
||||||
|
queueLimit := maxInt(4, projection.MaxJobs*2)
|
||||||
|
if queued >= queueLimit {
|
||||||
|
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureQueueLimit)
|
||||||
|
}
|
||||||
|
if projection.LogBacklogBatches >= capacityLogBacklogLimit || projection.ArtifactBacklogChunks >= capacityArtifactBacklogLimit || len(endpoint.Capacity.PressureCodes) > 0 {
|
||||||
|
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureBacklog)
|
||||||
|
}
|
||||||
|
return projection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) upsertAlert(candidate domain.AlertRecord) (domain.AlertRecord, error) {
|
||||||
|
stamp := svc.now()
|
||||||
|
candidate.ID = alertIDForSource(candidate.SourceKind, candidate.SourceID, candidate.RuleKey)
|
||||||
|
existing, err := svc.store.Alerts().Get(candidate.ID)
|
||||||
|
if err == nil {
|
||||||
|
existing.Severity = candidate.Severity
|
||||||
|
existing.State = domain.AlertStateActive
|
||||||
|
existing.Title = candidate.Title
|
||||||
|
existing.Message = safeBridgeReason(candidate.Message)
|
||||||
|
existing.OccurrenceCount++
|
||||||
|
existing.Retryable = candidate.Retryable
|
||||||
|
existing.RetryAfterSeconds = candidate.RetryAfterSeconds
|
||||||
|
existing.LastJobID = candidate.LastJobID
|
||||||
|
existing.LastAuditEventID = candidate.LastAuditEventID
|
||||||
|
existing.LastSeenAt = stamp
|
||||||
|
existing.ResolvedBy = ""
|
||||||
|
existing.ResolvedAt = time.Time{}
|
||||||
|
existing.ResolutionNote = ""
|
||||||
|
existing.UpdatedAt = stamp
|
||||||
|
if err := validator.ValidateAlertRecord(existing); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
if err := svc.store.Alerts().Update(existing); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, repo.ErrNotFound) {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
candidate.State = domain.AlertStateActive
|
||||||
|
candidate.Message = safeBridgeReason(candidate.Message)
|
||||||
|
candidate.OccurrenceCount = 1
|
||||||
|
candidate.LastSeenAt = stamp
|
||||||
|
candidate.CreatedAt = stamp
|
||||||
|
candidate.UpdatedAt = stamp
|
||||||
|
if err := validator.ValidateAlertRecord(candidate); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
if err := svc.store.Alerts().Create(candidate); err != nil {
|
||||||
|
return domain.AlertRecord{}, err
|
||||||
|
}
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) resolveAlertForSource(sourceKind, sourceID, ruleKey, actorID, note, auditID string) error {
|
||||||
|
alert, err := svc.store.Alerts().Get(alertIDForSource(sourceKind, sourceID, ruleKey))
|
||||||
|
if errors.Is(err, repo.ErrNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if alert.State == domain.AlertStateResolved {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stamp := svc.now()
|
||||||
|
alert.State = domain.AlertStateResolved
|
||||||
|
alert.ResolvedBy = actorID
|
||||||
|
alert.ResolvedAt = stamp
|
||||||
|
alert.ResolutionNote = note
|
||||||
|
alert.LastAuditEventID = auditID
|
||||||
|
alert.UpdatedAt = stamp
|
||||||
|
return svc.store.Alerts().Update(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) canAccessAlert(user domain.User, alert domain.AlertRecord) bool {
|
||||||
|
if isPlatformAdmin(user) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch alert.SourceKind {
|
||||||
|
case "server-instance":
|
||||||
|
instance, err := svc.store.ServerInstances().Get(alert.SourceID)
|
||||||
|
return err == nil && canAccessServer(user, instance)
|
||||||
|
case "run-endpoint":
|
||||||
|
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: alert.SourceID})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, instance := range instances {
|
||||||
|
if canAccessServer(user, instance) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "plugin-lifecycle":
|
||||||
|
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
||||||
|
return err == nil && canAccessServer(user, instance)
|
||||||
|
case "ai-config-diff":
|
||||||
|
preview, err := svc.store.AIConfigDiffs().Get(alert.SourceID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||||
|
return err == nil && canAccessServer(user, instance)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
defer svc.productionMu.Unlock()
|
||||||
|
stamp := svc.now()
|
||||||
|
installation := domain.PluginLifecycleInstallation{ID: pluginLifecycleInstallationID(plugin.ID, instance.ID), PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStateFailed, LastOperation: request.Operation, Compatibility: "incompatible", DependencyState: domain.DependencyStateUnknown, FailureReason: safeBridgeReason(reason), IdempotencyKey: request.IdempotencyKey, CreatedAt: stamp, UpdatedAt: stamp}
|
||||||
|
if existing, err := svc.store.PluginLifecycles().Get(installation.ID); err == nil {
|
||||||
|
installation.CreatedAt = existing.CreatedAt
|
||||||
|
}
|
||||||
|
return svc.pluginLifecycleDeniedLocked(actorID, installation, request, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
||||||
|
stamp := svc.now()
|
||||||
|
auditID, err := svc.recordAuditEventWithID(actorID, "plugin.lifecycle.denied", "plugin-lifecycle", installation.ID, domain.AuditResultDenied, reason)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
installation.CurrentState = domain.PluginLifecycleStateFailed
|
||||||
|
installation.LastOperation = request.Operation
|
||||||
|
installation.TargetVersion = request.TargetVersion
|
||||||
|
installation.FailureReason = safeBridgeReason(reason)
|
||||||
|
installation.AuditEventID = auditID
|
||||||
|
installation.UpdatedAt = stamp
|
||||||
|
alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.compatibility", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle compatibility check failed", Message: installation.FailureReason, Retryable: true, LastAuditEventID: auditID})
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
installation.AlertID = alert.ID
|
||||||
|
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := svc.store.PluginLifecycles().Get(installation.ID); errors.Is(err, repo.ErrNotFound) {
|
||||||
|
err = svc.store.PluginLifecycles().Create(installation)
|
||||||
|
} else if err == nil {
|
||||||
|
err = svc.store.PluginLifecycles().Update(installation)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.PluginLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Alert: &alert, Status: "denied"}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginLifecycleDispatchMetadata(plugin domain.GamePlugin, operation domain.PluginLifecycleOperation) (string, string, error) {
|
||||||
|
switch operation {
|
||||||
|
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback:
|
||||||
|
if plugin.LifecycleActions.Install == "" {
|
||||||
|
return "", "", validationError("plugin install action is not declared")
|
||||||
|
}
|
||||||
|
return domain.LifecycleCapabilityInstall, plugin.LifecycleActions.Install, nil
|
||||||
|
case domain.PluginLifecycleOperationEnable:
|
||||||
|
if plugin.LifecycleActions.Start == "" {
|
||||||
|
return "", "", validationError("plugin start action is not declared")
|
||||||
|
}
|
||||||
|
return domain.LifecycleCapabilityStart, plugin.LifecycleActions.Start, nil
|
||||||
|
case domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationRetire:
|
||||||
|
if plugin.LifecycleActions.Stop == "" {
|
||||||
|
return "", "", validationError("plugin stop action is not declared")
|
||||||
|
}
|
||||||
|
return domain.LifecycleCapabilityStop, plugin.LifecycleActions.Stop, nil
|
||||||
|
case domain.PluginLifecycleOperationDependencyCheck:
|
||||||
|
return domain.JobCapabilityDependenciesCheck, "dependencies/plugin", nil
|
||||||
|
default:
|
||||||
|
return "", "", validationError("plugin lifecycle operation is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePluginLifecycleTransition(installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest) error {
|
||||||
|
switch request.Operation {
|
||||||
|
case domain.PluginLifecycleOperationInstall:
|
||||||
|
if installation.CurrentState == domain.PluginLifecycleStateInstalled || installation.CurrentState == domain.PluginLifecycleStateEnabled || installation.CurrentState == domain.PluginLifecycleStateDisabled {
|
||||||
|
return validationError("plugin is already installed")
|
||||||
|
}
|
||||||
|
case domain.PluginLifecycleOperationEnable:
|
||||||
|
if installation.CurrentState != domain.PluginLifecycleStateInstalled && installation.CurrentState != domain.PluginLifecycleStateDisabled {
|
||||||
|
return validationError("plugin must be installed or disabled before enable")
|
||||||
|
}
|
||||||
|
case domain.PluginLifecycleOperationDisable:
|
||||||
|
if installation.CurrentState != domain.PluginLifecycleStateEnabled {
|
||||||
|
return validationError("plugin must be enabled before disable")
|
||||||
|
}
|
||||||
|
case domain.PluginLifecycleOperationUpgrade:
|
||||||
|
if installation.CurrentVersion == "" || request.TargetVersion == installation.CurrentVersion {
|
||||||
|
return validationError("upgrade requires a different target version")
|
||||||
|
}
|
||||||
|
case domain.PluginLifecycleOperationRollback:
|
||||||
|
if installation.PreviousVersion == "" {
|
||||||
|
return validationError("rollback requires a retained previous version")
|
||||||
|
}
|
||||||
|
case domain.PluginLifecycleOperationRetire:
|
||||||
|
if installation.CurrentState == domain.PluginLifecycleStateRetired {
|
||||||
|
return validationError("plugin is already retired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func desiredPluginLifecycleState(operation domain.PluginLifecycleOperation, installation domain.PluginLifecycleInstallation) domain.PluginLifecycleState {
|
||||||
|
switch operation {
|
||||||
|
case domain.PluginLifecycleOperationInstall:
|
||||||
|
return domain.PluginLifecycleStateInstalled
|
||||||
|
case domain.PluginLifecycleOperationEnable:
|
||||||
|
return domain.PluginLifecycleStateEnabled
|
||||||
|
case domain.PluginLifecycleOperationDisable:
|
||||||
|
return domain.PluginLifecycleStateDisabled
|
||||||
|
case domain.PluginLifecycleOperationRetire:
|
||||||
|
return domain.PluginLifecycleStateRetired
|
||||||
|
default:
|
||||||
|
return installation.DesiredState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dispatchedPluginLifecycleState(operation domain.PluginLifecycleOperation, current domain.PluginLifecycleState) domain.PluginLifecycleState {
|
||||||
|
switch operation {
|
||||||
|
case domain.PluginLifecycleOperationUpgrade:
|
||||||
|
return domain.PluginLifecycleStateUpgrading
|
||||||
|
case domain.PluginLifecycleOperationRollback:
|
||||||
|
return domain.PluginLifecycleStateRollingBack
|
||||||
|
case domain.PluginLifecycleOperationInstall:
|
||||||
|
return domain.PluginLifecycleStatePending
|
||||||
|
default:
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPluginLifecycleSuccess(installation *domain.PluginLifecycleInstallation) {
|
||||||
|
switch installation.LastOperation {
|
||||||
|
case domain.PluginLifecycleOperationInstall:
|
||||||
|
installation.CurrentVersion = installation.TargetVersion
|
||||||
|
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||||
|
case domain.PluginLifecycleOperationEnable:
|
||||||
|
installation.CurrentState = domain.PluginLifecycleStateEnabled
|
||||||
|
case domain.PluginLifecycleOperationDisable:
|
||||||
|
installation.CurrentState = domain.PluginLifecycleStateDisabled
|
||||||
|
case domain.PluginLifecycleOperationUpgrade:
|
||||||
|
installation.CurrentVersion = installation.TargetVersion
|
||||||
|
installation.CurrentState = installation.DesiredState
|
||||||
|
if installation.CurrentState != domain.PluginLifecycleStateEnabled && installation.CurrentState != domain.PluginLifecycleStateDisabled {
|
||||||
|
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||||
|
}
|
||||||
|
case domain.PluginLifecycleOperationRollback:
|
||||||
|
current := installation.CurrentVersion
|
||||||
|
installation.CurrentVersion = installation.PreviousVersion
|
||||||
|
installation.PreviousVersion = current
|
||||||
|
installation.TargetVersion = installation.CurrentVersion
|
||||||
|
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||||
|
case domain.PluginLifecycleOperationRetire:
|
||||||
|
installation.CurrentState = domain.PluginLifecycleStateRetired
|
||||||
|
case domain.PluginLifecycleOperationDependencyCheck:
|
||||||
|
installation.DependencyState = domain.DependencyStatePresent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertIDForSource(sourceKind, sourceID, ruleKey string) string {
|
||||||
|
sum := sha256.Sum256([]byte(sourceKind + "\x00" + sourceID + "\x00" + ruleKey))
|
||||||
|
return "alert-" + hex.EncodeToString(sum[:12])
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string {
|
||||||
|
sum := sha256.Sum256([]byte(pluginID + "\x00" + serverInstanceID))
|
||||||
|
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiConfigDiffID(requestID, serverInstanceID string) string {
|
||||||
|
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
|
||||||
|
return "ai-config-diff-" + hex.EncodeToString(sum[:12])
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCapacityPressure(codes []domain.CapacityPressureCode, code domain.CapacityPressureCode) []domain.CapacityPressureCode {
|
||||||
|
if !containsCapacityPressure(codes, code) {
|
||||||
|
return append(codes, code)
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsCapacityPressure(codes []domain.CapacityPressureCode, target domain.CapacityPressureCode) bool {
|
||||||
|
for _, code := range codes {
|
||||||
|
if code == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstCapacityCapability(capabilities []string) string {
|
||||||
|
for _, capability := range capabilities {
|
||||||
|
if strings.TrimSpace(capability) != "" {
|
||||||
|
return capability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "control.heartbeat"
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultAlertNote(note, fallback string) string {
|
||||||
|
if strings.TrimSpace(note) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return safeBridgeReason(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProductionCapacityCreatesDurableAlertAndSupportsClosure(t *testing.T) {
|
||||||
|
svc, session, instance := newProductionOpsFixture(t)
|
||||||
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get endpoint: %v", err)
|
||||||
|
}
|
||||||
|
endpoint.Capacity.RunningJobs = endpoint.Capacity.MaxJobs
|
||||||
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||||
|
t.Fatalf("update endpoint pressure: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := svc.CheckCapacityAdmissionForSession(session, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityInstall, IdempotencyKey: "capacity-pressure"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check capacity: %v", err)
|
||||||
|
}
|
||||||
|
if decision.Accepted || decision.State != domain.CapacityAdmissionDeferred || decision.AlertID == "" || decision.AuditEventID == "" {
|
||||||
|
t.Fatalf("expected durable deferred decision, got %+v", decision)
|
||||||
|
}
|
||||||
|
alerts, err := svc.ListAlertsForSession(session, domain.AlertFilter{State: domain.AlertStateActive})
|
||||||
|
if err != nil || len(alerts) != 1 || alerts[0].OccurrenceCount != 1 {
|
||||||
|
t.Fatalf("expected one active alert, got %+v err=%v", alerts, err)
|
||||||
|
}
|
||||||
|
acknowledged, err := svc.AcknowledgeAlertForSession(session, domain.AlertAcknowledgeRequest{AlertID: decision.AlertID, Note: "operator reviewing queue pressure"})
|
||||||
|
if err != nil || acknowledged.State != domain.AlertStateAcknowledged || acknowledged.AcknowledgedBy == "" {
|
||||||
|
t.Fatalf("acknowledge alert: %+v err=%v", acknowledged, err)
|
||||||
|
}
|
||||||
|
resolved, err := svc.ResolveAlertForSession(session, domain.AlertResolveRequest{AlertID: decision.AlertID, Note: "capacity policy reviewed"})
|
||||||
|
if err != nil || resolved.State != domain.AlertStateResolved || resolved.ResolvedBy == "" {
|
||||||
|
t.Fatalf("resolve alert: %+v err=%v", resolved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
|
||||||
|
svc, session, instance := newProductionOpsFixture(t)
|
||||||
|
request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"}
|
||||||
|
first, err := svc.RunPluginLifecycleForSession(session, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dispatch plugin install: %v", err)
|
||||||
|
}
|
||||||
|
second, err := svc.RunPluginLifecycleForSession(session, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repeat plugin install: %v", err)
|
||||||
|
}
|
||||||
|
if first.Job.ID == "" || second.Job.ID != first.Job.ID {
|
||||||
|
t.Fatalf("expected one idempotent job, got first=%+v second=%+v", first.Job, second.Job)
|
||||||
|
}
|
||||||
|
drift := request
|
||||||
|
drift.TargetVersion = "1.1.0"
|
||||||
|
if _, err := svc.RunPluginLifecycleForSession(session, drift); err == nil || !strings.Contains(err.Error(), "idempotencyKey") {
|
||||||
|
t.Fatalf("expected immutable input conflict, got %v", err)
|
||||||
|
}
|
||||||
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||||
|
if err != nil || len(jobs) != 1 {
|
||||||
|
t.Fatalf("expected exactly one lifecycle job, got %+v err=%v", jobs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) {
|
||||||
|
svc, session, instance := newProductionOpsFixture(t)
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get plugin: %v", err)
|
||||||
|
}
|
||||||
|
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.lifecycle")
|
||||||
|
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionPluginLifecycle))
|
||||||
|
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
|
||||||
|
Key: "operations", Title: "Operations", Path: "/operations",
|
||||||
|
Permissions: []string{"server.lifecycle"}, BridgeActions: []string{string(domain.PluginBridgeActionPluginLifecycle)},
|
||||||
|
})
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("update plugin bridge declaration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||||
|
RequestID: "bridge-plugin-install", PluginID: plugin.ID, RouteKey: "operations",
|
||||||
|
ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionPluginLifecycle,
|
||||||
|
Payload: map[string]string{"operation": "install", "targetVersion": plugin.Version, "idempotencyKey": "bridge-plugin-install-v1", "confirmed": "false"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute lifecycle bridge: %v", err)
|
||||||
|
}
|
||||||
|
if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" || response.Result["admissionState"] != string(domain.CapacityAdmissionAccepted) {
|
||||||
|
t.Fatalf("expected Platform-governed lifecycle job, got %+v", response)
|
||||||
|
}
|
||||||
|
serialized := strings.ToLower(strings.Join([]string{
|
||||||
|
response.Result["jobId"], response.Result["installationId"], response.Result["currentState"],
|
||||||
|
response.Result["desiredState"], response.Result["alertId"], response.Result["auditEventId"],
|
||||||
|
response.Result["admissionState"], response.Result["admissionReason"],
|
||||||
|
}, " "))
|
||||||
|
for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} {
|
||||||
|
if strings.Contains(serialized, forbidden) {
|
||||||
|
t.Fatalf("bridge lifecycle result exposed forbidden fragment %q: %s", forbidden, serialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) {
|
||||||
|
svc, session, instance := newProductionOpsFixture(t)
|
||||||
|
provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create provider: %v", err)
|
||||||
|
}
|
||||||
|
response, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-1", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invoke AI: %v", err)
|
||||||
|
}
|
||||||
|
if response.ConfigRecommendation == nil || response.ConfigRecommendation.DiffID == "" {
|
||||||
|
t.Fatalf("expected persisted config recommendation, got %+v", response)
|
||||||
|
}
|
||||||
|
jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||||
|
if len(jobs) != 0 {
|
||||||
|
t.Fatalf("AI recommendation must not dispatch before approval: %+v", jobs)
|
||||||
|
}
|
||||||
|
approved, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("approve AI diff: %v", err)
|
||||||
|
}
|
||||||
|
if approved.Preview.State != domain.AIConfigDiffStateApproved || approved.Dispatch.Job.ID == "" {
|
||||||
|
t.Fatalf("expected approved diff and queued job, got %+v", approved)
|
||||||
|
}
|
||||||
|
repeated, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
|
||||||
|
if err != nil || repeated.Dispatch.Job.ID != approved.Dispatch.Job.ID {
|
||||||
|
t.Fatalf("repeat approval must return original job: %+v err=%v", repeated, err)
|
||||||
|
}
|
||||||
|
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||||
|
if len(jobs) != 1 {
|
||||||
|
t.Fatalf("approval must dispatch exactly one job, got %+v", jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
staleResponse, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-stale", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invoke stale AI candidate: %v", err)
|
||||||
|
}
|
||||||
|
stored, err := svc.store.ServerInstances().Get(instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get server: %v", err)
|
||||||
|
}
|
||||||
|
stored.ConfigVersion++
|
||||||
|
if err := svc.store.ServerInstances().Update(stored); err != nil {
|
||||||
|
t.Fatalf("advance config revision: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: staleResponse.ConfigRecommendation.DiffID, IdempotencyKey: "approve-stale"}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
|
||||||
|
t.Fatalf("expected stale revision rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProductionOpsFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
|
||||||
|
t.Helper()
|
||||||
|
svc := newTestCoreService()
|
||||||
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
plugin.SupportedOS = []string{"linux"}
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("update plugin platform: %v", err)
|
||||||
|
}
|
||||||
|
endpoint.Platform = "linux"
|
||||||
|
endpoint.Architecture = "amd64"
|
||||||
|
endpoint.LastHeartbeatAt = fixedTime
|
||||||
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||||
|
t.Fatalf("update endpoint metadata: %v", err)
|
||||||
|
}
|
||||||
|
session := createServiceUserAndLogin(t, svc, domain.User{ID: "production-owner", DisplayName: "Production Owner", Email: "production-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||||
|
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "production-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Production Server", State: domain.ServerInstanceStateReady})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create server: %v", err)
|
||||||
|
}
|
||||||
|
binding := domain.RuntimeBinding{ID: "runtime-binding-" + instance.ID, ServerInstanceID: instance.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}
|
||||||
|
if err := svc.store.RuntimeBindings().Create(binding); err != nil && !errors.Is(err, repo.ErrDuplicate) {
|
||||||
|
t.Fatalf("create runtime binding: %v", err)
|
||||||
|
}
|
||||||
|
return svc, session, instance
|
||||||
|
}
|
||||||
@@ -82,17 +82,21 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
|
|||||||
if timeout > selected.TimeoutSeconds || attempts > selected.MaxAttempts {
|
if timeout > selected.TimeoutSeconds || attempts > selected.MaxAttempts {
|
||||||
return domain.RemoteAdapterResult{}, validationError("remote adapter timeout or retry exceeds declaration")
|
return domain.RemoteAdapterResult{}, validationError("remote adapter timeout or retry exceeds declaration")
|
||||||
}
|
}
|
||||||
|
inputRef := request.InputRef
|
||||||
|
if inputRef == "" {
|
||||||
|
inputRef = fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey)
|
||||||
|
}
|
||||||
job := domain.Job{
|
job := domain.Job{
|
||||||
ID: jobIDFromParts("job-remote-adapter", instance.ID, request.IdempotencyKey),
|
ID: jobIDFromParts("job-remote-adapter", instance.ID, request.IdempotencyKey),
|
||||||
ServerInstanceID: instance.ID,
|
ServerInstanceID: instance.ID,
|
||||||
RunEndpointID: instance.RunEndpointID,
|
RunEndpointID: instance.RunEndpointID,
|
||||||
Capability: request.Capability,
|
Capability: request.Capability,
|
||||||
TargetKey: request.TargetKey,
|
TargetKey: request.TargetKey,
|
||||||
InputRef: fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey),
|
InputRef: inputRef,
|
||||||
IdempotencyKey: request.IdempotencyKey,
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
Progress: domain.JobProgress{Percent: 0, Message: "scoped remote adapter queued"},
|
Progress: domain.JobProgress{Percent: 0, Message: "scoped remote adapter queued"},
|
||||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: attempts, InitialBackoffSeconds: 2, MaxBackoffSeconds: 30},
|
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: attempts, InitialBackoffSeconds: 2, MaxBackoffSeconds: 30},
|
||||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout},
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout, Inputs: domain.CopyStringMap(request.Inputs)},
|
||||||
}
|
}
|
||||||
created, err := svc.CreateJob(job)
|
created, err := svc.CreateJob(job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) {
|
||||||
|
svc := newTestCoreService()
|
||||||
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||||
|
plugin.Permissions.RemoteAccess = true
|
||||||
|
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
||||||
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||||
|
plugin.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}}
|
||||||
|
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "player-lookup", Kind: "sqlite", TargetKey: "scum-db.player-lookup", Capabilities: []string{capability}})
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||||
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-remote-owner", DisplayName: "Remote Owner", Email: "remote-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||||
|
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-remote-input", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Remote Input"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inputs := map[string]string{"playerId": "steam-123", "limit": "25"}
|
||||||
|
result, err := svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "player-lookup", TargetKey: "scum-db.player-lookup", Capability: capability, IdempotencyKey: "lookup-1", InputRef: "input://scum-db/player-lookup/lookup-1", Inputs: inputs})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request remote adapter: %v", err)
|
||||||
|
}
|
||||||
|
inputs["playerId"] = "mutated"
|
||||||
|
job, err := svc.store.Jobs().Get(result.RequestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if job.InputRef != "input://scum-db/player-lookup/lookup-1" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["limit"] != "25" {
|
||||||
|
t.Fatalf("typed inputs were not propagated: %#v", job)
|
||||||
|
}
|
||||||
|
helloRequest := validRunControlHello()
|
||||||
|
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability)
|
||||||
|
helloRequest.CapabilityReport.Fingerprint = "cap-remote-inputs"
|
||||||
|
hello, err := svc.RegisterRunHello(helloRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register Run hello: %v", err)
|
||||||
|
}
|
||||||
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim remote adapter job: %v", err)
|
||||||
|
}
|
||||||
|
if !claim.HasJob || claim.Job == nil || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.Inputs["playerId"] != "steam-123" || claim.Job.ExecutionInput.Inputs["limit"] != "25" {
|
||||||
|
t.Fatalf("typed inputs were not propagated to real Run claim: %#v", claim.Job)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := dto.RunJobAssignmentFromDomain(*claim.Job)
|
||||||
|
payload, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal Run assignment response: %v", err)
|
||||||
|
}
|
||||||
|
var wire struct {
|
||||||
|
ExecutionInput struct {
|
||||||
|
Inputs map[string]string `json:"inputs"`
|
||||||
|
} `json:"executionInput"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||||
|
t.Fatalf("unmarshal Run assignment response: %v", err)
|
||||||
|
}
|
||||||
|
if wire.ExecutionInput.Inputs["playerId"] != "steam-123" || wire.ExecutionInput.Inputs["limit"] != "25" {
|
||||||
|
t.Fatalf("typed inputs were not preserved in Run assignment JSON: %s", payload)
|
||||||
|
}
|
||||||
|
wire.ExecutionInput.Inputs["playerId"] = "wire-mutated"
|
||||||
|
if claim.Job.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
||||||
|
t.Fatal("Run assignment DTO aliases domain remote inputs")
|
||||||
|
}
|
||||||
|
claim.Job.ExecutionInput.Inputs["playerId"] = "assignment-mutated"
|
||||||
|
stored, err := svc.store.Jobs().Get(result.RequestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get claimed remote adapter job: %v", err)
|
||||||
|
}
|
||||||
|
if stored.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
||||||
|
t.Fatal("real Run claim aliases persisted remote inputs")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,28 @@ var (
|
|||||||
ErrForbidden = errors.New("forbidden")
|
ErrForbidden = errors.New("forbidden")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ForbiddenError struct {
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ForbiddenError) Error() string {
|
||||||
|
if strings.TrimSpace(err.Reason) == "" {
|
||||||
|
return ErrForbidden.Error()
|
||||||
|
}
|
||||||
|
return ErrForbidden.Error() + ": " + err.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ForbiddenError) Is(target error) bool {
|
||||||
|
return target == ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
func forbiddenError(reason string) error {
|
||||||
|
if strings.TrimSpace(reason) == "" {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return ForbiddenError{Reason: reason}
|
||||||
|
}
|
||||||
|
|
||||||
type Core interface {
|
type Core interface {
|
||||||
CreateUser(domain.User) (domain.User, error)
|
CreateUser(domain.User) (domain.User, error)
|
||||||
UpdateUser(string, domain.User) (domain.User, error)
|
UpdateUser(string, domain.User) (domain.User, error)
|
||||||
@@ -79,6 +101,16 @@ type Core interface {
|
|||||||
ArchiveServerInstanceForSession(string, string) (domain.ServerInstance, error)
|
ArchiveServerInstanceForSession(string, string) (domain.ServerInstance, error)
|
||||||
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
|
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
|
||||||
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
||||||
|
GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error)
|
||||||
|
CheckCapacityAdmissionForSession(string, domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error)
|
||||||
|
ListAlertsForSession(string, domain.AlertFilter) ([]domain.AlertRecord, error)
|
||||||
|
AcknowledgeAlertForSession(string, domain.AlertAcknowledgeRequest) (domain.AlertRecord, error)
|
||||||
|
ResolveAlertForSession(string, domain.AlertResolveRequest) (domain.AlertRecord, error)
|
||||||
|
RetryAlertForSession(string, domain.AlertRetryRequest) (domain.AlertRetryResult, error)
|
||||||
|
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
||||||
|
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error)
|
||||||
|
ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||||
|
ApproveAIConfigDiffForSession(string, domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error)
|
||||||
IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error)
|
IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error)
|
||||||
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
|
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
|
||||||
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
|
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
|
||||||
@@ -137,6 +169,17 @@ type Core interface {
|
|||||||
RegisterClientManager(domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error)
|
RegisterClientManager(domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error)
|
||||||
AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error)
|
AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error)
|
||||||
ReconcileClientManagerLifecycle() error
|
ReconcileClientManagerLifecycle() error
|
||||||
|
QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error)
|
||||||
|
ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error)
|
||||||
|
AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error)
|
||||||
|
CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error)
|
||||||
|
CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error)
|
||||||
|
UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error)
|
||||||
|
ReconcileGameClientBridgeCommands() error
|
||||||
|
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
|
||||||
|
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
||||||
|
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
|
||||||
|
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
|
||||||
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
|
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
|
||||||
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
|
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
|
||||||
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
|
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
|
||||||
@@ -169,6 +212,8 @@ type CoreService struct {
|
|||||||
runSessions map[string]domain.RunControlSession
|
runSessions map[string]domain.RunControlSession
|
||||||
runSessionSeq uint64
|
runSessionSeq uint64
|
||||||
jobMu sync.Mutex
|
jobMu sync.Mutex
|
||||||
|
bridgeMu sync.Mutex
|
||||||
|
bridgeSeq uint64
|
||||||
logStore LogBodyStore
|
logStore LogBodyStore
|
||||||
artifactStore ArtifactBodyStore
|
artifactStore ArtifactBodyStore
|
||||||
artifactMu sync.Mutex
|
artifactMu sync.Mutex
|
||||||
@@ -177,6 +222,7 @@ type CoreService struct {
|
|||||||
artifactTransferSeq uint64
|
artifactTransferSeq uint64
|
||||||
auditMu sync.Mutex
|
auditMu sync.Mutex
|
||||||
auditSeq uint64
|
auditSeq uint64
|
||||||
|
productionMu sync.Mutex
|
||||||
aiProviderClient AIProviderClient
|
aiProviderClient AIProviderClient
|
||||||
secretEnvelope SecretEnvelope
|
secretEnvelope SecretEnvelope
|
||||||
}
|
}
|
||||||
@@ -237,6 +283,9 @@ func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, ar
|
|||||||
if err := service.ReconcileClientManagerLifecycle(); err != nil {
|
if err := service.ReconcileClientManagerLifecycle(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := service.ReconcileGameClientBridgeCommands(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return service, nil
|
return service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,13 +587,13 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
|
|||||||
|
|
||||||
result := domain.AIProviderTestResult{
|
result := domain.AIProviderTestResult{
|
||||||
ProviderID: provider.ID,
|
ProviderID: provider.ID,
|
||||||
Mode: "metadata",
|
Mode: "provider",
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "metadata validation passed",
|
Message: "provider invocation passed",
|
||||||
}
|
}
|
||||||
if err := validator.ValidateAIProvider(provider); err != nil {
|
if err := validator.ValidateAIProvider(provider); err != nil {
|
||||||
result.Success = false
|
result.Success = false
|
||||||
result.Message = "metadata validation failed"
|
result.Message = "provider validation failed"
|
||||||
var validationErr validator.ValidationError
|
var validationErr validator.ValidationError
|
||||||
if errors.As(err, &validationErr) {
|
if errors.As(err, &validationErr) {
|
||||||
result.Violations = append(result.Violations, validationErr.Violations...)
|
result.Violations = append(result.Violations, validationErr.Violations...)
|
||||||
@@ -554,9 +603,35 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
|
|||||||
}
|
}
|
||||||
if provider.Status != domain.AIProviderStatusActive {
|
if provider.Status != domain.AIProviderStatusActive {
|
||||||
result.Success = false
|
result.Success = false
|
||||||
result.Message = "metadata validation failed"
|
result.Message = "provider validation failed"
|
||||||
result.Violations = append(result.Violations, "provider must be active")
|
result.Violations = append(result.Violations, "provider must be active")
|
||||||
}
|
}
|
||||||
|
if !result.Success {
|
||||||
|
return domain.CopyAIProviderTestResult(result), nil
|
||||||
|
}
|
||||||
|
_, invokeErr := svc.aiProviderClient.Invoke(provider, domain.AIInvocationRequest{RequestID: "provider-test-" + provider.ID, Purpose: "provider.health", Prompt: "Return a short health acknowledgement.", Model: provider.DefaultModel})
|
||||||
|
if invokeErr != nil {
|
||||||
|
result.Success = false
|
||||||
|
result.Message = "provider invocation failed safely"
|
||||||
|
result.Violations = []string{"provider invocation failed safely"}
|
||||||
|
auditID, auditErr := svc.recordAuditEventWithID("platform", "ai.provider.test.failed", "ai-provider", provider.ID, domain.AuditResultFailed, result.Message)
|
||||||
|
if auditErr != nil {
|
||||||
|
return domain.AIProviderTestResult{}, auditErr
|
||||||
|
}
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider health check failed", Message: result.Message, Retryable: false, LastAuditEventID: auditID})
|
||||||
|
svc.productionMu.Unlock()
|
||||||
|
if alertErr != nil {
|
||||||
|
return domain.AIProviderTestResult{}, alertErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
svc.productionMu.Lock()
|
||||||
|
resolveErr := svc.resolveAlertForSource("ai-provider", provider.ID, "ai.provider.failed", "platform", "AI provider health check passed", "")
|
||||||
|
svc.productionMu.Unlock()
|
||||||
|
if resolveErr != nil {
|
||||||
|
return domain.AIProviderTestResult{}, resolveErr
|
||||||
|
}
|
||||||
|
}
|
||||||
return domain.CopyAIProviderTestResult(result), nil
|
return domain.CopyAIProviderTestResult(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +659,7 @@ func (svc *CoreService) CreateGamePlugin(plugin domain.GamePlugin) (domain.GameP
|
|||||||
if plugin.Status == "" {
|
if plugin.Status == "" {
|
||||||
plugin.Status = domain.GamePluginStatusInstalled
|
plugin.Status = domain.GamePluginStatusInstalled
|
||||||
}
|
}
|
||||||
|
plugin.ProductionLifecycle = normalizedProductionLifecycle(plugin.ProductionLifecycle)
|
||||||
if err := validator.ValidateGamePlugin(plugin); err != nil {
|
if err := validator.ValidateGamePlugin(plugin); err != nil {
|
||||||
return domain.GamePlugin{}, err
|
return domain.GamePlugin{}, err
|
||||||
}
|
}
|
||||||
@@ -621,8 +697,10 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
|
|||||||
Pages: manifest.Pages,
|
Pages: manifest.Pages,
|
||||||
Tags: manifest.Tags,
|
Tags: manifest.Tags,
|
||||||
AIPurposes: manifest.AI.Purposes,
|
AIPurposes: manifest.AI.Purposes,
|
||||||
|
ProductionLifecycle: manifest.ProductionLifecycle,
|
||||||
RemoteAccess: manifest.RemoteAccess,
|
RemoteAccess: manifest.RemoteAccess,
|
||||||
RuntimeProfiles: manifest.RuntimeProfiles,
|
RuntimeProfiles: manifest.RuntimeProfiles,
|
||||||
|
GameClientBridge: manifest.GameClientBridge,
|
||||||
Status: domain.GamePluginStatusInstalled,
|
Status: domain.GamePluginStatusInstalled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -714,6 +792,8 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma
|
|||||||
base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload)
|
base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload)
|
||||||
case domain.PluginBridgeActionClientManager:
|
case domain.PluginBridgeActionClientManager:
|
||||||
base = svc.executeBridgeClientManager(sessionID, base, request)
|
base = svc.executeBridgeClientManager(sessionID, base, request)
|
||||||
|
case domain.PluginBridgeActionPluginLifecycle:
|
||||||
|
base = svc.executeBridgePluginLifecycle(sessionID, base, request)
|
||||||
case domain.PluginBridgeActionArtifactsOpen:
|
case domain.PluginBridgeActionArtifactsOpen:
|
||||||
base = svc.executeBridgeArtifactOpen(sessionID, base, request)
|
base = svc.executeBridgeArtifactOpen(sessionID, base, request)
|
||||||
case domain.PluginBridgeActionAIInvoke:
|
case domain.PluginBridgeActionAIInvoke:
|
||||||
@@ -775,7 +855,6 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
|||||||
RouteKey: request.RouteKey,
|
RouteKey: request.RouteKey,
|
||||||
ServerInstanceID: request.ServerInstanceID,
|
ServerInstanceID: request.ServerInstanceID,
|
||||||
Purpose: request.AIPurpose,
|
Purpose: request.AIPurpose,
|
||||||
ProviderID: request.Payload["providerId"],
|
|
||||||
Model: request.Payload["model"],
|
Model: request.Payload["model"],
|
||||||
Prompt: defaultBridgeValue(request.Payload["prompt"], "Review the current server context and provide a safe recommendation."),
|
Prompt: defaultBridgeValue(request.Payload["prompt"], "Review the current server context and provide a safe recommendation."),
|
||||||
CurrentConfig: request.Payload["currentConfig"],
|
CurrentConfig: request.Payload["currentConfig"],
|
||||||
@@ -795,6 +874,9 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
|||||||
if response.ConfigRecommendation != nil {
|
if response.ConfigRecommendation != nil {
|
||||||
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
|
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
|
||||||
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
|
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
|
||||||
|
base.Result["diffId"] = response.ConfigRecommendation.DiffID
|
||||||
|
base.Result["key"] = response.ConfigRecommendation.Key
|
||||||
|
base.Result["expiresAt"] = response.ConfigRecommendation.ExpiresAt
|
||||||
}
|
}
|
||||||
if response.Error != nil {
|
if response.Error != nil {
|
||||||
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
||||||
@@ -802,6 +884,22 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
||||||
|
confirmed, err := strconv.ParseBool(defaultBridgeValue(request.Payload["confirmed"], "false"))
|
||||||
|
if err != nil {
|
||||||
|
base.Status = "error"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "confirmed must be true or false"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: request.PluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Payload["operation"]), TargetVersion: request.Payload["targetVersion"], IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID), Confirmed: confirmed})
|
||||||
|
if err != nil {
|
||||||
|
return bridgeExecutionError(base, err)
|
||||||
|
}
|
||||||
|
base.Status = result.Status
|
||||||
|
base.Result = map[string]string{"installationId": result.Installation.ID, "currentState": string(result.Installation.CurrentState), "desiredState": string(result.Installation.DesiredState), "jobId": result.Job.ID, "alertId": result.Installation.AlertID, "auditEventId": result.Installation.AuditEventID, "admissionState": string(result.Decision.State), "admissionReason": result.Decision.Reason}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
|
func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
|
||||||
capability := strings.TrimSpace(payload["capability"])
|
capability := strings.TrimSpace(payload["capability"])
|
||||||
if capability == "" {
|
if capability == "" {
|
||||||
@@ -951,7 +1049,53 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
|
|||||||
}
|
}
|
||||||
timeoutSeconds, _ := strconv.Atoi(payload["timeoutSeconds"])
|
timeoutSeconds, _ := strconv.Atoi(payload["timeoutSeconds"])
|
||||||
maxAttempts, _ := strconv.Atoi(payload["maxAttempts"])
|
maxAttempts, _ := strconv.Atoi(payload["maxAttempts"])
|
||||||
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID)})
|
inputs := map[string]string{}
|
||||||
|
for key, value := range payload {
|
||||||
|
if strings.HasPrefix(key, "input.") {
|
||||||
|
inputs[strings.TrimPrefix(key, "input.")] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if capability == domain.JobCapabilityRemoteRunDBSQLiteQuery {
|
||||||
|
templateKey := strings.TrimSpace(inputs["templateKey"])
|
||||||
|
if templateKey == "" {
|
||||||
|
base.Status = "error"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "input.templateKey is required for sqlite query requests"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
template, reason := findBridgeQueryTemplate(plugin, base.RouteKey, templateKey)
|
||||||
|
if reason != "" {
|
||||||
|
base.Status = "denied"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "query_template_denied", Message: reason}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if template.Engine != "sqlite" || template.TransportKey != declarationKey || template.TargetKey != payload["targetKey"] {
|
||||||
|
base.Status = "denied"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "query_template_denied", Message: "query template transport or target is not approved"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if timeoutSeconds == 0 {
|
||||||
|
timeoutSeconds = template.TimeoutSeconds
|
||||||
|
} else if timeoutSeconds > template.TimeoutSeconds {
|
||||||
|
base.Status = "error"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "query template timeout limit exceeded"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
maxRows := template.MaxRows
|
||||||
|
if requestedRows, ok := inputs["maxRows"]; ok && strings.TrimSpace(requestedRows) != "" {
|
||||||
|
parsedRows, parseErr := strconv.Atoi(requestedRows)
|
||||||
|
if parseErr != nil || parsedRows <= 0 {
|
||||||
|
base.Status = "error"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "input.maxRows must be a positive integer"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if parsedRows < maxRows {
|
||||||
|
maxRows = parsedRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputs["templateKey"] = template.Key
|
||||||
|
inputs["maxRows"] = strconv.Itoa(maxRows)
|
||||||
|
}
|
||||||
|
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), InputRef: payload["inputRef"], Inputs: inputs})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bridgeExecutionError(base, err)
|
return bridgeExecutionError(base, err)
|
||||||
}
|
}
|
||||||
@@ -967,6 +1111,46 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findBridgeQueryTemplate(plugin domain.GamePlugin, routeKey string, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, string) {
|
||||||
|
pageFound := false
|
||||||
|
pageAllowsTemplate := false
|
||||||
|
for _, page := range plugin.GameClientBridge.Pages {
|
||||||
|
if page.PageKey != routeKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pageFound = true
|
||||||
|
if containsString(page.QueryTemplateKeys, templateKey) {
|
||||||
|
pageAllowsTemplate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !pageFound || !pageAllowsTemplate {
|
||||||
|
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template is not declared by the bridge page"
|
||||||
|
}
|
||||||
|
var selected domain.GameClientBridgeQueryTemplateDeclaration
|
||||||
|
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||||
|
if template.Key == templateKey {
|
||||||
|
selected = template
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected.Key == "" {
|
||||||
|
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template is not declared by the plugin"
|
||||||
|
}
|
||||||
|
for _, page := range plugin.Pages {
|
||||||
|
if page.Key != routeKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !containsString(page.Permissions, selected.Permission) {
|
||||||
|
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template permission is not declared by the plugin page"
|
||||||
|
}
|
||||||
|
if !containsString(page.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) {
|
||||||
|
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template page does not declare remote access"
|
||||||
|
}
|
||||||
|
return selected, ""
|
||||||
|
}
|
||||||
|
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template plugin page is not declared"
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
||||||
distribution, err := svc.GenerateRunDistributionForSession(sessionID, domain.RunDistributionGenerateRequest{
|
distribution, err := svc.GenerateRunDistributionForSession(sessionID, domain.RunDistributionGenerateRequest{
|
||||||
ServerInstanceID: request.ServerInstanceID,
|
ServerInstanceID: request.ServerInstanceID,
|
||||||
@@ -1278,14 +1462,29 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
|
|||||||
Pages: plugin.Pages,
|
Pages: plugin.Pages,
|
||||||
Tags: plugin.Tags,
|
Tags: plugin.Tags,
|
||||||
AIPurposes: plugin.AIPurposes,
|
AIPurposes: plugin.AIPurposes,
|
||||||
|
ProductionLifecycle: plugin.ProductionLifecycle,
|
||||||
RemoteAccess: plugin.RemoteAccess,
|
RemoteAccess: plugin.RemoteAccess,
|
||||||
RuntimeProfiles: plugin.RuntimeProfiles,
|
RuntimeProfiles: plugin.RuntimeProfiles,
|
||||||
|
GameClientBridge: plugin.GameClientBridge,
|
||||||
ValidationViolations: plugin.ValidationViolations,
|
ValidationViolations: plugin.ValidationViolations,
|
||||||
Status: plugin.Status,
|
Status: plugin.Status,
|
||||||
Source: "platform-registry",
|
Source: "platform-registry",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizedProductionLifecycle(lifecycle domain.GamePluginProductionLifecycle) domain.GamePluginProductionLifecycle {
|
||||||
|
if len(lifecycle.Operations) == 0 {
|
||||||
|
lifecycle.Operations = []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}
|
||||||
|
}
|
||||||
|
if lifecycle.DependencyPolicy == "" {
|
||||||
|
lifecycle.DependencyPolicy = "optional"
|
||||||
|
}
|
||||||
|
if len(lifecycle.ApprovalRequired) == 0 {
|
||||||
|
lifecycle.ApprovalRequired = []string{"disable", "rollback", "retire"}
|
||||||
|
}
|
||||||
|
return lifecycle
|
||||||
|
}
|
||||||
|
|
||||||
func marketplacePluginMatchesKeyword(plugin domain.PluginMarketplacePlugin, keyword string) bool {
|
func marketplacePluginMatchesKeyword(plugin domain.PluginMarketplacePlugin, keyword string) bool {
|
||||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||||
if keyword == "" {
|
if keyword == "" {
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ func TestCoreServiceManagesAIProviderMetadata(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("test enabled provider: %v", err)
|
t.Fatalf("test enabled provider: %v", err)
|
||||||
}
|
}
|
||||||
if !testResult.Success || testResult.Mode != "metadata" {
|
if !testResult.Success || testResult.Mode != "provider" {
|
||||||
t.Fatalf("expected metadata test success, got %+v", testResult)
|
t.Fatalf("expected metadata test success, got %+v", testResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,6 +1022,147 @@ func TestCoreServiceRemoteAccessRequiresPluginDeclaration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) {
|
||||||
|
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||||
|
|
||||||
|
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||||
|
RequestID: "query-template-dispatch-1",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RouteKey: "remote",
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||||
|
Payload: map[string]string{
|
||||||
|
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||||
|
"declarationKey": "scum-db-read",
|
||||||
|
"targetKey": "scum-db.player-lookup",
|
||||||
|
"idempotencyKey": "query-template-dispatch-1",
|
||||||
|
"input.templateKey": "players.by-id",
|
||||||
|
"input.playerId": "steam-123",
|
||||||
|
"input.maxRows": "100",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute declared sqlite query template: %v", err)
|
||||||
|
}
|
||||||
|
if queued.Status != "queued" || queued.Result["jobId"] == "" {
|
||||||
|
t.Fatalf("expected queued query template job, got %+v", queued)
|
||||||
|
}
|
||||||
|
job, err := svc.store.Jobs().Get(queued.Result["jobId"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get query template job: %v", err)
|
||||||
|
}
|
||||||
|
if job.ExecutionInput.TimeoutSeconds != 20 {
|
||||||
|
t.Fatalf("expected template timeout 20, got %+v", job.ExecutionInput)
|
||||||
|
}
|
||||||
|
if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" {
|
||||||
|
t.Fatalf("expected typed bounded query template inputs, got %#v", job.ExecutionInput.Inputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceDeniesUndeclaredOrMismatchedSQLiteQueryTemplateBeforeJob(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
templateKey string
|
||||||
|
declarationKey string
|
||||||
|
targetKey string
|
||||||
|
}{
|
||||||
|
{name: "undeclared template", templateKey: "players.unknown", declarationKey: "scum-db-read", targetKey: "scum-db.player-lookup"},
|
||||||
|
{name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "scum-db.player-lookup"},
|
||||||
|
{name: "mismatched target", templateKey: "players.by-id", declarationKey: "scum-db-read", targetKey: "scum-db.other"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||||
|
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||||
|
RequestID: "query-template-denied-1",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RouteKey: "remote",
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||||
|
Payload: map[string]string{
|
||||||
|
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||||
|
"declarationKey": test.declarationKey,
|
||||||
|
"targetKey": test.targetKey,
|
||||||
|
"idempotencyKey": "query-template-denied-1",
|
||||||
|
"input.templateKey": test.templateKey,
|
||||||
|
"input.playerId": "steam-123",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute denied sqlite query template: %v", err)
|
||||||
|
}
|
||||||
|
if result.Status != "denied" || result.Error == nil || result.Error.Code != "query_template_denied" {
|
||||||
|
t.Fatalf("expected query template denial, got %+v", result)
|
||||||
|
}
|
||||||
|
jobs, err := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list jobs after denial: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobs) != 0 {
|
||||||
|
t.Fatalf("query template denial created jobs: %+v", jobs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing.T) {
|
||||||
|
_, plugin, _, _, _ := createSQLiteQueryBridgeFixture(t)
|
||||||
|
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.game-client.read"
|
||||||
|
|
||||||
|
for index := range plugin.Pages {
|
||||||
|
if plugin.Pages[index].Key == "remote" {
|
||||||
|
plugin.Pages[index].Permissions = []string{"server.remote.access"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "permission") {
|
||||||
|
t.Fatalf("expected query template permission denial, got %q", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := range plugin.Pages {
|
||||||
|
if plugin.Pages[index].Key == "remote" {
|
||||||
|
plugin.Pages[index].Permissions = []string{"server.remote.access"}
|
||||||
|
plugin.Pages[index].BridgeActions = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.remote.access"
|
||||||
|
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "remote access") {
|
||||||
|
t.Fatalf("expected query template remote access denial, got %q", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) {
|
||||||
|
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||||
|
|
||||||
|
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||||
|
RequestID: "query-template-sql-rejected-1",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RouteKey: "remote",
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||||
|
Payload: map[string]string{
|
||||||
|
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||||
|
"declarationKey": "scum-db-read",
|
||||||
|
"targetKey": "scum-db.player-lookup",
|
||||||
|
"idempotencyKey": "query-template-sql-rejected-1",
|
||||||
|
"input.templateKey": "players.by-id",
|
||||||
|
"input.sqlText": "SELECT * FROM users",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute arbitrary SQL bridge input: %v", err)
|
||||||
|
}
|
||||||
|
if result.Status != "error" || result.Error == nil || !strings.Contains(strings.ToLower(result.Error.Message), "unsafe") {
|
||||||
|
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result)
|
||||||
|
}
|
||||||
|
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||||
|
if listErr != nil {
|
||||||
|
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr)
|
||||||
|
}
|
||||||
|
if len(jobs) != 0 {
|
||||||
|
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
|
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
registration := validPluginManifestRegistration()
|
registration := validPluginManifestRegistration()
|
||||||
@@ -1212,6 +1353,80 @@ func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlug
|
|||||||
return plugin, endpoint
|
return plugin, endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlugin, domain.RunEndpoint, string, domain.ServerInstance) {
|
||||||
|
t.Helper()
|
||||||
|
svc := newTestCoreService()
|
||||||
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||||
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||||
|
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
||||||
|
plugin.Permissions.RemoteAccess = true
|
||||||
|
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||||
|
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
|
||||||
|
Key: "remote",
|
||||||
|
Title: "Remote",
|
||||||
|
Path: "/remote",
|
||||||
|
Permissions: []string{"server.remote.access"},
|
||||||
|
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
|
||||||
|
})
|
||||||
|
plugin.RemoteAccess = domain.GamePluginRemoteAccess{
|
||||||
|
Methods: []string{"run"},
|
||||||
|
RunCapabilities: []string{capability},
|
||||||
|
DatabaseEngines: []string{"sqlite"},
|
||||||
|
}
|
||||||
|
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
|
||||||
|
Key: "scum-db-read",
|
||||||
|
Kind: "sqlite",
|
||||||
|
TargetKey: "scum-db.player-lookup",
|
||||||
|
Capabilities: []string{capability},
|
||||||
|
})
|
||||||
|
plugin.GameClientBridge = domain.GameClientBridgeManifest{
|
||||||
|
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
|
||||||
|
{
|
||||||
|
Key: "players.by-id",
|
||||||
|
Title: "Player lookup",
|
||||||
|
Permission: "server.remote.access",
|
||||||
|
Engine: "sqlite",
|
||||||
|
TransportKey: "scum-db-read",
|
||||||
|
TargetKey: "scum-db.player-lookup",
|
||||||
|
ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json",
|
||||||
|
ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json",
|
||||||
|
MaxRows: 25,
|
||||||
|
TimeoutSeconds: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100},
|
||||||
|
Pages: []domain.GameClientBridgePageContract{
|
||||||
|
{PageKey: "remote", QueryTemplateKeys: []string{"players.by-id"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("update sqlite query plugin fixture: %v", err)
|
||||||
|
}
|
||||||
|
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||||
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||||
|
t.Fatalf("update sqlite query endpoint fixture: %v", err)
|
||||||
|
}
|
||||||
|
session := createServiceUserAndLogin(t, svc, domain.User{
|
||||||
|
ID: "user-query-owner",
|
||||||
|
DisplayName: "Query Owner",
|
||||||
|
Email: "query-owner@example.test",
|
||||||
|
Roles: []string{"server-owner"},
|
||||||
|
PasswordHash: "secret-password",
|
||||||
|
})
|
||||||
|
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
|
||||||
|
ID: "server-query-template",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RunEndpointID: endpoint.ID,
|
||||||
|
Name: "Query Template Server",
|
||||||
|
State: domain.ServerInstanceStateRunning,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlite query server fixture: %v", err)
|
||||||
|
}
|
||||||
|
return svc, plugin, endpoint, session, instance
|
||||||
|
}
|
||||||
|
|
||||||
func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domain.ServerInstance, profileKey string) domain.RuntimeBinding {
|
func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domain.ServerInstance, profileKey string) domain.RuntimeBinding {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
@@ -1269,7 +1484,8 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
|
|||||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
|
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||||
|
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,68 @@ func ValidateClientManagerLifecycleInputRequest(value domain.ClientManagerLifecy
|
|||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateClientManagerCompanionConfigInput(value domain.ClientManagerCompanionConfigInput) error {
|
||||||
|
var violations []string
|
||||||
|
if value.SchemaVersion != domain.ClientManagerCompanionConfigSchemaVersion {
|
||||||
|
violations = append(violations, "schemaVersion is invalid")
|
||||||
|
}
|
||||||
|
for field, content := range map[string]string{
|
||||||
|
"configTemplateKey": value.ConfigTemplateKey, "configTemplateRef": value.ConfigTemplateRef, "configOutputRef": value.ConfigOutputRef,
|
||||||
|
"configSchemaRef": value.ConfigSchemaRef, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID,
|
||||||
|
"pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version,
|
||||||
|
"sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "proofMaterialEnv": value.ProofMaterialEnv,
|
||||||
|
} {
|
||||||
|
violations = appendRequired(violations, field, content)
|
||||||
|
}
|
||||||
|
if !clientManagerIdentifierPattern.MatchString(value.ConfigTemplateKey) {
|
||||||
|
violations = append(violations, "configTemplateKey is invalid")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateSafeRelativeRuntimePath("configTemplateRef", value.ConfigTemplateRef)...)
|
||||||
|
if value.ConfigOutputRef != "config.yaml" {
|
||||||
|
violations = append(violations, "configOutputRef must be config.yaml")
|
||||||
|
}
|
||||||
|
if !safeRelativeJSONRef(value.ConfigSchemaRef) {
|
||||||
|
violations = append(violations, "configSchemaRef must be a safe relative JSON reference")
|
||||||
|
}
|
||||||
|
if value.ConfigFormat != "yaml" || value.PlatformBaseURLSource != "run-control" || value.RegistrationProof != "hmac-sha256" || value.ProofMaterialSource != "component-package" || value.SessionMode != "component-session" || value.TLSPolicy != "verify-system-roots" {
|
||||||
|
violations = append(violations, "companion bootstrap security policy is invalid")
|
||||||
|
}
|
||||||
|
if !validCompanionProofEnvironment(value.ProofMaterialEnv) {
|
||||||
|
violations = append(violations, "proofMaterialEnv is invalid")
|
||||||
|
}
|
||||||
|
for field, identifier := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID} {
|
||||||
|
if !clientManagerIdentifierPattern.MatchString(identifier) {
|
||||||
|
violations = append(violations, field+" is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch)
|
||||||
|
if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 {
|
||||||
|
violations = append(violations, "keyGeneration and deploymentGeneration must be positive")
|
||||||
|
}
|
||||||
|
if len(value.Capabilities) == 0 || len(value.Capabilities) > 32 {
|
||||||
|
violations = append(violations, "capabilities must be bounded")
|
||||||
|
}
|
||||||
|
capabilities := make(map[string]struct{}, len(value.Capabilities))
|
||||||
|
for _, capability := range value.Capabilities {
|
||||||
|
if !clientManagerIdentifierPattern.MatchString(capability) {
|
||||||
|
violations = append(violations, "capability is invalid")
|
||||||
|
}
|
||||||
|
if _, exists := capabilities[capability]; exists {
|
||||||
|
violations = append(violations, "capabilities must be unique")
|
||||||
|
}
|
||||||
|
capabilities[capability] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, required := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
|
||||||
|
if _, exists := capabilities[required]; !exists {
|
||||||
|
violations = append(violations, "capabilities must include "+required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.HeartbeatIntervalSeconds < 5 || value.HeartbeatIntervalSeconds > 300 || value.CommandPollIntervalSeconds < 1 || value.CommandPollIntervalSeconds > 60 || value.RequestTimeoutSeconds < 1 || value.RequestTimeoutSeconds > 60 {
|
||||||
|
violations = append(violations, "companion timing policy is invalid")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateClientManagerRegisterRequest(value domain.ClientManagerRegisterRequest) error {
|
func ValidateClientManagerRegisterRequest(value domain.ClientManagerRegisterRequest) error {
|
||||||
var violations []string
|
var violations []string
|
||||||
for field, content := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "nonce": value.Nonce, "signature": value.Signature} {
|
for field, content := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "nonce": value.Nonce, "signature": value.Signature} {
|
||||||
|
|||||||
@@ -60,12 +60,17 @@ func ValidateRunControlHeartbeat(heartbeat domain.RunControlHeartbeat) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendCapacityViolations(violations []string, capacity domain.RunCapacity) []string {
|
func appendCapacityViolations(violations []string, capacity domain.RunCapacity) []string {
|
||||||
if capacity.MaxJobs < 0 || capacity.RunningJobs < 0 || capacity.QueuedJobs < 0 {
|
if capacity.MaxJobs < 0 || capacity.RunningJobs < 0 || capacity.QueuedJobs < 0 || capacity.LogBacklogBatches < 0 || capacity.ArtifactBacklogChunks < 0 {
|
||||||
violations = append(violations, "capacity counts must not be negative")
|
violations = append(violations, "capacity counts must not be negative")
|
||||||
}
|
}
|
||||||
if capacity.MaxJobs > 0 && capacity.RunningJobs > capacity.MaxJobs {
|
if capacity.MaxJobs > 0 && capacity.RunningJobs > capacity.MaxJobs {
|
||||||
violations = append(violations, "runningJobs must not exceed maxJobs")
|
violations = append(violations, "runningJobs must not exceed maxJobs")
|
||||||
}
|
}
|
||||||
|
for i, code := range capacity.PressureCodes {
|
||||||
|
if strings.TrimSpace(code) == "" || strings.ContainsAny(code, " \t\r\n") || containsUnsafeRuntimeSecret(code) || looksLikeRawHostPath(code) {
|
||||||
|
violations = append(violations, fmt.Sprintf("pressureCodes[%d] is invalid", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxGameClientBridgeArrayItems = 4096
|
||||||
|
maxGameClientBridgeIdentifierLength = 180
|
||||||
|
maxGameClientBridgeObjectKeys = 64
|
||||||
|
maxGameClientBridgePayloadDepth = 16
|
||||||
|
maxGameClientBridgePayloadNodes = 8192
|
||||||
|
maxGameClientBridgePayloadSize = 64 * 1024
|
||||||
|
maxGameClientBridgePayloadString = 16 * 1024
|
||||||
|
maxGameClientBridgeSessionLength = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
gameClientBridgeAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`)
|
||||||
|
gameClientBridgeCamelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||||
|
gameClientBridgeNonWord = regexp.MustCompile(`[^A-Za-z0-9]+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateGameClientBridgeQueueRequest(request domain.GameClientBridgeQueueRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", request.ServerInstanceID, true)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "pluginId", request.PluginID, true)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "profileKey", request.ProfileKey, true)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "commandType", request.CommandType, true)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "idempotencyKey", request.IdempotencyKey, true)
|
||||||
|
if request.ExpiresAt.IsZero() {
|
||||||
|
violations = append(violations, "expiresAt is required")
|
||||||
|
}
|
||||||
|
if request.Priority < 0 || request.Priority > 100 {
|
||||||
|
violations = append(violations, "priority must be between 0 and 100")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateGameClientBridgeClaimRequest(request domain.GameClientBridgeClaimRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendGameClientBridgeSession(violations, request.SessionToken)
|
||||||
|
if request.Limit < 0 || request.Limit > 50 {
|
||||||
|
violations = append(violations, "limit must be between 0 and 50")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateGameClientBridgeAckRequest(request domain.GameClientBridgeAckRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendGameClientBridgeSession(violations, request.SessionToken)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
|
||||||
|
if request.FencingToken == 0 {
|
||||||
|
violations = append(violations, "fencingToken is required")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateGameClientBridgeResultRequest(request domain.GameClientBridgeResultRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendGameClientBridgeSession(violations, request.SessionToken)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
|
||||||
|
if request.FencingToken == 0 {
|
||||||
|
violations = append(violations, "fencingToken is required")
|
||||||
|
}
|
||||||
|
if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultCancelled {
|
||||||
|
violations = append(violations, "status is invalid")
|
||||||
|
}
|
||||||
|
violations = appendGameClientBridgeText(violations, "summary", request.Summary, 512)
|
||||||
|
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateGameClientBridgeCancelRequest(request domain.GameClientBridgeCancelRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
|
||||||
|
violations = appendGameClientBridgeText(violations, "reason", request.Reason, 256)
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateGameClientBridgeSnapshotIngestRequest(request domain.GameClientBridgeSnapshotIngestRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendGameClientBridgeSession(violations, request.SessionToken)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "type", request.Type, true)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "schemaVersion", request.SchemaVersion, true)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "streamKey", request.StreamKey, true)
|
||||||
|
if request.Sequence == 0 {
|
||||||
|
violations = append(violations, "sequence must be positive")
|
||||||
|
}
|
||||||
|
if request.ObservedAt.IsZero() {
|
||||||
|
violations = append(violations, "observedAt is required")
|
||||||
|
}
|
||||||
|
if request.Retention.KeepForSeconds <= 0 || request.Retention.KeepForSeconds > 31*24*60*60 {
|
||||||
|
violations = append(violations, "retention.keepForSeconds must be between 1 and 2678400")
|
||||||
|
}
|
||||||
|
if request.Retention.MaxRecords < 0 || request.Retention.MaxRecords > 10000 {
|
||||||
|
violations = append(violations, "retention.maxRecords must be between 0 and 10000")
|
||||||
|
}
|
||||||
|
if request.Payload == nil {
|
||||||
|
violations = append(violations, "payload is required")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateGameClientBridgeSnapshotQuery(query domain.GameClientBridgeSnapshotQuery) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", query.ServerInstanceID, true)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "pluginId", query.PluginID, true)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "profileKey", query.ProfileKey, false)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "type", query.Type, false)
|
||||||
|
violations = appendGameClientBridgeIdentifier(violations, "streamKey", query.StreamKey, false)
|
||||||
|
if query.Limit < 0 || query.Limit > 200 {
|
||||||
|
violations = append(violations, "limit must be between 0 and 200")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendGameClientBridgeIdentifier(violations []string, field, value string, required bool) []string {
|
||||||
|
if value == "" {
|
||||||
|
if required {
|
||||||
|
return append(violations, field+" is required")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maxGameClientBridgeIdentifierLength {
|
||||||
|
return append(violations, field+" is invalid")
|
||||||
|
}
|
||||||
|
first, _ := utf8.DecodeRuneInString(value)
|
||||||
|
last, _ := utf8.DecodeLastRuneInString(value)
|
||||||
|
if !isGameClientBridgeASCIIAlphanumeric(first) || !isGameClientBridgeASCIIAlphanumeric(last) {
|
||||||
|
return append(violations, field+" is invalid")
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if !isGameClientBridgeIdentifierCharacter(character) {
|
||||||
|
return append(violations, field+" is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(value, "://") || strings.Contains(value, "..") {
|
||||||
|
return append(violations, field+" is invalid")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGameClientBridgeASCIIAlphanumeric(character rune) bool {
|
||||||
|
return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGameClientBridgeIdentifierCharacter(character rune) bool {
|
||||||
|
return character >= 'a' && character <= 'z' ||
|
||||||
|
character >= 'A' && character <= 'Z' ||
|
||||||
|
character >= '0' && character <= '9' ||
|
||||||
|
character == '.' || character == '_' || character == '-' || character == ':'
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendGameClientBridgeSession(violations []string, value string) []string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return append(violations, "sessionToken is required")
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || len(value) > maxGameClientBridgeSessionLength || containsControlCharacter(value) {
|
||||||
|
return append(violations, "sessionToken is invalid")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendGameClientBridgeText(violations []string, field, value string, maximum int) []string {
|
||||||
|
if value == "" {
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maximum || containsControlCharacter(value) {
|
||||||
|
violations = append(violations, field+" is invalid")
|
||||||
|
}
|
||||||
|
lowered := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) {
|
||||||
|
violations = append(violations, field+" contains unsafe connection or host material")
|
||||||
|
}
|
||||||
|
for _, reason := range unsafePluginStringReasons(value) {
|
||||||
|
violations = append(violations, field+": "+reason)
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsControlCharacter(value string) bool {
|
||||||
|
for _, character := range value {
|
||||||
|
if unicode.IsControl(character) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type gameClientBridgePayloadBudget struct {
|
||||||
|
nodes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateGameClientBridgePayload(payload map[string]any) []string {
|
||||||
|
if payload == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
budget := gameClientBridgePayloadBudget{}
|
||||||
|
violations := validateGameClientBridgePayloadValue("payload", payload, 0, &budget)
|
||||||
|
if len(violations) != 0 {
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return []string{"payload must be valid JSON"}
|
||||||
|
}
|
||||||
|
if len(encoded) > maxGameClientBridgePayloadSize {
|
||||||
|
return []string{"payload is too large"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateGameClientBridgePayloadValue(field string, value any, depth int, budget *gameClientBridgePayloadBudget) []string {
|
||||||
|
budget.nodes++
|
||||||
|
if budget.nodes > maxGameClientBridgePayloadNodes {
|
||||||
|
return []string{"payload has too many values"}
|
||||||
|
}
|
||||||
|
if depth > maxGameClientBridgePayloadDepth {
|
||||||
|
return []string{"payload nesting is too deep"}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil, bool:
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
return validateGameClientBridgePayloadString(field, typed)
|
||||||
|
case float64:
|
||||||
|
if math.IsInf(typed, 0) || math.IsNaN(typed) {
|
||||||
|
return []string{field + " must be a finite JSON number"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case float32:
|
||||||
|
if math.IsInf(float64(typed), 0) || math.IsNaN(float64(typed)) {
|
||||||
|
return []string{field + " must be a finite JSON number"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||||
|
return nil
|
||||||
|
case json.Number:
|
||||||
|
if _, err := json.Marshal(typed); err != nil {
|
||||||
|
return []string{field + " must be a valid JSON number"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case map[string]any:
|
||||||
|
if len(typed) > maxGameClientBridgeObjectKeys {
|
||||||
|
return []string{field + " has too many keys"}
|
||||||
|
}
|
||||||
|
var violations []string
|
||||||
|
for key, item := range typed {
|
||||||
|
if !validGameClientBridgePayloadKey(key) {
|
||||||
|
violations = append(violations, field+" key "+fmt.Sprintf("%q", key)+" is invalid")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if unsafeGameClientBridgePayloadKey(key) {
|
||||||
|
violations = append(violations, field+" contains forbidden key "+key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
violations = append(violations, validateGameClientBridgePayloadValue(field+"."+key, item, depth+1, budget)...)
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
case []any:
|
||||||
|
if len(typed) > maxGameClientBridgeArrayItems {
|
||||||
|
return []string{field + " has too many items"}
|
||||||
|
}
|
||||||
|
var violations []string
|
||||||
|
for index, item := range typed {
|
||||||
|
violations = append(violations, validateGameClientBridgePayloadValue(fmt.Sprintf("%s[%d]", field, index), item, depth+1, budget)...)
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
default:
|
||||||
|
return []string{fmt.Sprintf("%s uses non-JSON type %T", field, value)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateGameClientBridgePayloadString(field, value string) []string {
|
||||||
|
var violations []string
|
||||||
|
if !utf8.ValidString(value) {
|
||||||
|
violations = append(violations, field+" must be valid UTF-8")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(value) > maxGameClientBridgePayloadString {
|
||||||
|
violations = append(violations, field+" is too long")
|
||||||
|
}
|
||||||
|
if containsControlCharacter(value) {
|
||||||
|
violations = append(violations, field+" contains control characters")
|
||||||
|
}
|
||||||
|
for _, reason := range unsafePluginStringReasons(value) {
|
||||||
|
violations = append(violations, field+": "+reason)
|
||||||
|
}
|
||||||
|
lowered := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if containsUnsafeRuntimeSecret(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) {
|
||||||
|
violations = append(violations, field+" contains unsafe connection material")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func validGameClientBridgePayloadKey(key string) bool {
|
||||||
|
if key == "" || !utf8.ValidString(key) || strings.TrimSpace(key) != key || utf8.RuneCountInString(key) > 80 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range key {
|
||||||
|
if !isGameClientBridgeIdentifierCharacter(character) || character == ':' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeGameClientBridgePayloadKey(key string) bool {
|
||||||
|
tokens := gameClientBridgePayloadKeyTokens(key)
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
normalized := strings.Join(tokens, "")
|
||||||
|
for _, exact := range []string{
|
||||||
|
"absolutepath", "apikey", "commandline", "componentkey", "credential", "credentials", "directsocket", "dsn", "hostpath", "password", "passwd", "rawpath", "rawsql", "runendpoint", "runsocket", "script", "secret", "sessiontoken", "shell", "socket", "sql", "statement", "terminalcommand",
|
||||||
|
} {
|
||||||
|
if normalized == strings.ReplaceAll(exact, " ", "") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if last := tokens[len(tokens)-1]; last == "password" || last == "passwd" || last == "secret" || last == "credential" || last == "credentials" || last == "dsn" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, sequence := range [][]string{
|
||||||
|
{"api", "key"},
|
||||||
|
{"access", "key"},
|
||||||
|
{"private", "key"},
|
||||||
|
{"auth", "token"},
|
||||||
|
{"access", "token"},
|
||||||
|
{"client", "secret"},
|
||||||
|
{"storage", "credential"},
|
||||||
|
{"component", "key"},
|
||||||
|
{"session", "token"},
|
||||||
|
{"host", "path"},
|
||||||
|
{"raw", "path"},
|
||||||
|
{"absolute", "path"},
|
||||||
|
{"file", "system", "path"},
|
||||||
|
{"direct", "socket"},
|
||||||
|
{"socket", "path"},
|
||||||
|
{"socket", "address"},
|
||||||
|
{"socket", "url"},
|
||||||
|
{"socket", "endpoint"},
|
||||||
|
{"run", "endpoint"},
|
||||||
|
{"run", "url"},
|
||||||
|
{"run", "socket"},
|
||||||
|
{"run", "token"},
|
||||||
|
{"run", "credential"},
|
||||||
|
{"raw", "sql"},
|
||||||
|
{"raw", "query"},
|
||||||
|
{"sql", "text"},
|
||||||
|
{"sql", "query"},
|
||||||
|
{"sql", "statement"},
|
||||||
|
{"arbitrary", "sql"},
|
||||||
|
{"shell", "command"},
|
||||||
|
{"shell", "script"},
|
||||||
|
{"script", "body"},
|
||||||
|
{"terminal", "command"},
|
||||||
|
{"command", "line"},
|
||||||
|
{"arbitrary", "shell"},
|
||||||
|
} {
|
||||||
|
if gameClientBridgeContainsSensitiveSequence(tokens, sequence) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsEmbeddedGameClientBridgeHostPath(value string) bool {
|
||||||
|
for _, marker := range []string{"/etc/", "/var/", "/tmp/", "/home/", "/root/", "/private/", "/users/", "/volumes/", "/opt/", `:\\`} {
|
||||||
|
if strings.Contains(value, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgeContainsSensitiveSequence(tokens, sequence []string) bool {
|
||||||
|
for start := 0; start+len(sequence) <= len(tokens); start++ {
|
||||||
|
matched := true
|
||||||
|
for index, expected := range sequence {
|
||||||
|
if tokens[start+index] != expected {
|
||||||
|
matched = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := start + len(sequence)
|
||||||
|
if end == len(tokens) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch tokens[end] {
|
||||||
|
case "address", "body", "content", "material", "path", "raw", "ref", "text", "url", "value":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameClientBridgePayloadKeyTokens(key string) []string {
|
||||||
|
withAcronymBoundaries := gameClientBridgeAcronymBoundary.ReplaceAllString(key, `${1} ${2}`)
|
||||||
|
withCamelBoundaries := gameClientBridgeCamelBoundary.ReplaceAllString(withAcronymBoundaries, `${1} ${2}`)
|
||||||
|
return strings.Fields(strings.ToLower(gameClientBridgeNonWord.ReplaceAllString(withCamelBoundaries, " ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasUnsafeGameClientBridgeReference(value string) bool {
|
||||||
|
for _, fragment := range []string{
|
||||||
|
"unix://", "tcp://", "mysql://", "postgres://", "postgresql://", "mongodb://", "redis://", "sqlite://", "sqlserver://", "mssql://", "odbc:", "secret://", "vault://", "env://", "http://127.", "https://127.", "http://localhost", "https://localhost",
|
||||||
|
} {
|
||||||
|
if strings.Contains(value, fragment) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest, domain.GamePluginRuntimeProfiles) {
|
||||||
|
bridge := domain.GameClientBridgeManifest{
|
||||||
|
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||||
|
Companion: domain.GameClientBridgeCompanionDeclaration{
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
ConfigTemplateKey: "client-config",
|
||||||
|
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||||
|
ConfigFormat: "yaml",
|
||||||
|
PlatformBaseURLSource: "run-control",
|
||||||
|
RegistrationProof: "hmac-sha256",
|
||||||
|
ProofMaterialSource: "component-package",
|
||||||
|
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||||
|
SessionMode: "component-session",
|
||||||
|
TLSPolicy: "verify-system-roots",
|
||||||
|
HeartbeatIntervalSeconds: 30,
|
||||||
|
CommandPollIntervalSeconds: 5,
|
||||||
|
RequestTimeoutSeconds: 15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{
|
||||||
|
Key: "scum-client-manager",
|
||||||
|
ConfigTemplates: []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}},
|
||||||
|
Health: domain.RuntimeClientManagerHealth{IntervalSeconds: 30, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}},
|
||||||
|
}}}
|
||||||
|
return bridge, profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
|
||||||
|
bridge, profiles := validGameClientBridgeCompanionManifest()
|
||||||
|
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, profiles); len(violations) != 0 {
|
||||||
|
t.Fatalf("expected valid companion declaration, got %v", violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expected string
|
||||||
|
mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles)
|
||||||
|
}{
|
||||||
|
{name: "undeclared profile", expected: "profileKey must reference", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
|
bridge.Companion.ProfileKey = "missing"
|
||||||
|
}},
|
||||||
|
{name: "undeclared template", expected: "configTemplateKey must reference", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
|
bridge.Companion.ConfigTemplateKey = "missing"
|
||||||
|
}},
|
||||||
|
{name: "unsafe schema", expected: "configSchemaRef", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
|
bridge.Companion.ConfigSchemaRef = "/etc/config.json"
|
||||||
|
}},
|
||||||
|
{name: "insecure tls", expected: "security policy", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
|
bridge.Companion.TLSPolicy = "skip-verification"
|
||||||
|
}},
|
||||||
|
{name: "heartbeat mismatch", expected: "must match", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
|
bridge.Companion.HeartbeatIntervalSeconds = 31
|
||||||
|
}},
|
||||||
|
{name: "missing bridge capability", expected: "game-client.bridge", mutate: func(_ *domain.GameClientBridgeManifest, profiles *domain.GamePluginRuntimeProfiles) {
|
||||||
|
profiles.ClientManagers[0].Health.RequiredCapabilities = []string{"component.register", "component.heartbeat", "component.health"}
|
||||||
|
}},
|
||||||
|
{name: "partial declaration", expected: "profile or config template key", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
|
bridge.Companion.ProfileKey = ""
|
||||||
|
}},
|
||||||
|
{name: "reserved proof environment", expected: "proofMaterialEnv", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
|
bridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest()
|
||||||
|
test.mutate(&candidateBridge, &candidateProfiles)
|
||||||
|
violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, candidateProfiles)
|
||||||
|
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
||||||
|
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateClientManagerCompanionConfigInputFailsClosed(t *testing.T) {
|
||||||
|
valid := domain.ClientManagerCompanionConfigInput{
|
||||||
|
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
|
||||||
|
ConfigTemplateKey: "client-config",
|
||||||
|
ConfigTemplateRef: "config.yaml.example",
|
||||||
|
ConfigOutputRef: "config.yaml",
|
||||||
|
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||||
|
ConfigFormat: "yaml",
|
||||||
|
PlatformBaseURLSource: "run-control",
|
||||||
|
InstallationID: "installation-1",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
PluginID: "game.scum",
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
ArtifactID: "artifact-1",
|
||||||
|
Version: "1.0.0",
|
||||||
|
SourceRevision: "revision-1",
|
||||||
|
TargetOS: "windows",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
KeyGeneration: 1,
|
||||||
|
DeploymentGeneration: 2,
|
||||||
|
Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"},
|
||||||
|
RegistrationProof: "hmac-sha256",
|
||||||
|
ProofMaterialSource: "component-package",
|
||||||
|
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||||
|
SessionMode: "component-session",
|
||||||
|
TLSPolicy: "verify-system-roots",
|
||||||
|
HeartbeatIntervalSeconds: 30,
|
||||||
|
CommandPollIntervalSeconds: 5,
|
||||||
|
RequestTimeoutSeconds: 15,
|
||||||
|
}
|
||||||
|
if err := ValidateClientManagerCompanionConfigInput(valid); err != nil {
|
||||||
|
t.Fatalf("expected valid companion input, got %v", err)
|
||||||
|
}
|
||||||
|
for name, mutate := range map[string]func(*domain.ClientManagerCompanionConfigInput){
|
||||||
|
"insecure tls": func(value *domain.ClientManagerCompanionConfigInput) { value.TLSPolicy = "skip-verification" },
|
||||||
|
"legacy session": func(value *domain.ClientManagerCompanionConfigInput) { value.SessionMode = "shared-token" },
|
||||||
|
"reserved env": func(value *domain.ClientManagerCompanionConfigInput) { value.ProofMaterialEnv = "PATH" },
|
||||||
|
"unsafe template": func(value *domain.ClientManagerCompanionConfigInput) { value.ConfigTemplateRef = "../config.yaml" },
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
candidate := domain.CopyClientManagerCompanionConfigInput(valid)
|
||||||
|
mutate(&candidate)
|
||||||
|
if err := ValidateClientManagerCompanionConfigInput(candidate); err == nil {
|
||||||
|
t.Fatalf("expected invalid companion input: %+v", candidate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validBridgeQueueRequest() domain.GameClientBridgeQueueRequest {
|
||||||
|
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-1", ExpiresAt: time.Now().UTC().Add(time.Minute)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGameClientBridgeRequests(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
checks := map[string]error{
|
||||||
|
"queue": ValidateGameClientBridgeQueueRequest(validBridgeQueueRequest()),
|
||||||
|
"claim": ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 50}),
|
||||||
|
"ack": ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1}),
|
||||||
|
"result": ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "completed"}),
|
||||||
|
"cancel": ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "operator request"}),
|
||||||
|
"snapshot": ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1.2.0", StreamKey: "current", Sequence: 1, ObservedAt: now, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600}}),
|
||||||
|
"query": ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Limit: 200}),
|
||||||
|
}
|
||||||
|
for name, err := range checks {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate %s request: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "queue identifier", err: func() error {
|
||||||
|
request := validBridgeQueueRequest()
|
||||||
|
request.CommandType = "bad/type"
|
||||||
|
return ValidateGameClientBridgeQueueRequest(request)
|
||||||
|
}(), want: "commandType"},
|
||||||
|
{name: "queue priority", err: func() error {
|
||||||
|
request := validBridgeQueueRequest()
|
||||||
|
request.Priority = 101
|
||||||
|
return ValidateGameClientBridgeQueueRequest(request)
|
||||||
|
}(), want: "priority"},
|
||||||
|
{name: "claim token", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: " session"}), want: "sessionToken"},
|
||||||
|
{name: "claim limit", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 51}), want: "limit"},
|
||||||
|
{name: "ack fence", err: ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1"}), want: "fencingToken"},
|
||||||
|
{name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unknown"}), want: "status"},
|
||||||
|
{name: "result text", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd"}), want: "unsafe"},
|
||||||
|
{name: "cancel text", err: ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private"}), want: "unsafe"},
|
||||||
|
{name: "snapshot payload", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "payload"},
|
||||||
|
{name: "snapshot sequence", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "sequence"},
|
||||||
|
{name: "snapshot retention", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 31*24*60*60 + 1}}), want: "keepForSeconds"},
|
||||||
|
{name: "query field", err: ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", StreamKey: "tcp://host"}), want: "streamKey"},
|
||||||
|
{name: "query limit", err: ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", Limit: 201}), want: "limit"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if test.err == nil || !strings.Contains(test.err.Error(), test.want) {
|
||||||
|
t.Fatalf("expected %q violation, got %v", test.want, test.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGameClientBridgePayloadAcceptsJSONValuesWithoutKeyFalsePositives(t *testing.T) {
|
||||||
|
request := validBridgeQueueRequest()
|
||||||
|
request.Payload = map[string]any{
|
||||||
|
"nullValue": nil, "enabled": true, "count": int(2), "ratio": float64(1.5), "sequence": json.Number("9007199254740993"), "nested": []any{map[string]any{"value": "safe"}},
|
||||||
|
"secretaryName": "Mina", "socketCount": 2, "apiKeyEnabled": false, "sqlQueryTemplateKey": "players.by-id", "shellStatus": "unavailable", "logicalPath": "snapshots.current", "monkey": "ordinary", "chatMessage": "Select your reward from the list",
|
||||||
|
}
|
||||||
|
if err := ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||||
|
t.Fatalf("valid JSON payload with benign keys rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGameClientBridgePayloadRejectsUnsafeKeyPatterns(t *testing.T) {
|
||||||
|
keys := []string{"sessionToken", "authToken", "accessKey", "privateKey", "component-key", "databasePassword", "clientSecret", "api_key", "databaseDSN", "storageCredential", "hostPath", "absolute_path", "directSocket", "socketAddress", "runEndpoint", "runUrl", "rawSQL", "rawQuery", "sqlText", "sql_statement", "shellCommand", "shell_script", "scriptBody", "terminalCommand", "commandLine"}
|
||||||
|
for _, key := range keys {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
request := validBridgeQueueRequest()
|
||||||
|
request.Payload = map[string]any{key: "value"}
|
||||||
|
err := ValidateGameClientBridgeQueueRequest(request)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "forbidden key") {
|
||||||
|
t.Fatalf("expected forbidden key rejection, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGameClientBridgePayloadRejectsUnsafeStringMaterial(t *testing.T) {
|
||||||
|
values := []string{"/etc/passwd", "prefix path=/var/run/run.sock", `C:\\Users\\operator\\secret.txt`, "tcp://127.0.0.1:9000", "unix:///var/run/run.sock", "http://localhost:9000", "mysql://user:password@host/db", "secret://component/key", "vault://runtime/token", "Bearer abc123", "password=leak"}
|
||||||
|
for index, value := range values {
|
||||||
|
request := validBridgeQueueRequest()
|
||||||
|
request.IdempotencyKey = fmt.Sprintf("case-%d", index)
|
||||||
|
request.Payload = map[string]any{"value": value}
|
||||||
|
if err := ValidateGameClientBridgeQueueRequest(request); err == nil || !strings.Contains(err.Error(), "payload") {
|
||||||
|
t.Fatalf("expected unsafe value %q rejection, got %v", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGameClientBridgePayloadRejectsNonJSONValuesAndInvalidNumbers(t *testing.T) {
|
||||||
|
tests := map[string]any{"typed map": map[string]string{"key": "value"}, "typed slice": []string{"value"}, "time": time.Now(), "channel": make(chan int), "not a number": math.NaN(), "infinity": math.Inf(1), "invalid number": json.Number("01")}
|
||||||
|
for name, value := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
request := validBridgeQueueRequest()
|
||||||
|
request.Payload = map[string]any{"value": value}
|
||||||
|
if err := ValidateGameClientBridgeQueueRequest(request); err == nil {
|
||||||
|
t.Fatalf("expected %s rejection", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGameClientBridgePayloadStructuralBudgets(t *testing.T) {
|
||||||
|
tests := map[string]map[string]any{}
|
||||||
|
tooManyKeys := map[string]any{}
|
||||||
|
for index := 0; index < maxGameClientBridgeObjectKeys+1; index++ {
|
||||||
|
tooManyKeys[fmt.Sprintf("key%d", index)] = index
|
||||||
|
}
|
||||||
|
tests["object keys"] = tooManyKeys
|
||||||
|
deep := map[string]any{"value": true}
|
||||||
|
for index := 0; index < maxGameClientBridgePayloadDepth+2; index++ {
|
||||||
|
deep = map[string]any{"nested": deep}
|
||||||
|
}
|
||||||
|
tests["depth"] = deep
|
||||||
|
tests["array items"] = map[string]any{"items": make([]any, maxGameClientBridgeArrayItems+1)}
|
||||||
|
tests["string"] = map[string]any{"message": strings.Repeat("x", maxGameClientBridgePayloadString+1)}
|
||||||
|
tests["encoded size"] = map[string]any{"one": strings.Repeat("x", 15000), "two": strings.Repeat("x", 15000), "three": strings.Repeat("x", 15000), "four": strings.Repeat("x", 15000), "five": strings.Repeat("x", 15000)}
|
||||||
|
tests["invalid key"] = map[string]any{"bad key": true}
|
||||||
|
tests["invalid utf8"] = map[string]any{"value": string([]byte{0xff})}
|
||||||
|
wide := map[string]any{}
|
||||||
|
for index := 0; index < 64; index++ {
|
||||||
|
wide[fmt.Sprintf("items%d", index)] = make([]any, 128)
|
||||||
|
}
|
||||||
|
tests["node budget"] = wide
|
||||||
|
cycle := map[string]any{}
|
||||||
|
cycle["self"] = cycle
|
||||||
|
tests["cycle"] = cycle
|
||||||
|
|
||||||
|
for name, payload := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
request := validBridgeQueueRequest()
|
||||||
|
request.Payload = payload
|
||||||
|
if err := ValidateGameClientBridgeQueueRequest(request); err == nil {
|
||||||
|
t.Fatalf("expected %s rejection", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,5 +96,28 @@ func ValidateRemoteAdapterRequest(request domain.RemoteAdapterRequest) error {
|
|||||||
if strings.ContainsAny(request.TargetKey, "\\\n\r") || strings.Contains(request.TargetKey, "://") || strings.ContainsAny(request.TargetKey, " ") {
|
if strings.ContainsAny(request.TargetKey, "\\\n\r") || strings.Contains(request.TargetKey, "://") || strings.ContainsAny(request.TargetKey, " ") {
|
||||||
violations = append(violations, "targetKey must be a logical key")
|
violations = append(violations, "targetKey must be a logical key")
|
||||||
}
|
}
|
||||||
|
if request.InputRef != "" && !validScopedInputRef(request.InputRef) {
|
||||||
|
violations = append(violations, "inputRef is not allowed")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateRemoteAdapterInputs("inputs", request.Inputs)...)
|
||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRemoteAdapterInputs(field string, inputs map[string]string) []string {
|
||||||
|
if len(inputs) > 32 {
|
||||||
|
return []string{field + " has too many values"}
|
||||||
|
}
|
||||||
|
var violations []string
|
||||||
|
for key, value := range inputs {
|
||||||
|
if !clientManagerIdentifierPattern.MatchString(key) || unsafeGameClientBridgePayloadKey(key) {
|
||||||
|
violations = append(violations, field+" key is invalid or unsafe")
|
||||||
|
}
|
||||||
|
if len([]rune(value)) > 2048 {
|
||||||
|
violations = append(violations, field+"."+key+" is too long")
|
||||||
|
}
|
||||||
|
for _, reason := range unsafePluginStringReasons(value) {
|
||||||
|
violations = append(violations, field+"."+key+": "+reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateCapacityAdmissionRequest(request domain.CapacityAdmissionRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "capability", request.Capability)
|
||||||
|
if request.ServerInstanceID != "" && !safeIdentifier(request.ServerInstanceID) {
|
||||||
|
violations = append(violations, "serverInstanceId is invalid")
|
||||||
|
}
|
||||||
|
if request.RunEndpointID != "" && !safeIdentifier(request.RunEndpointID) {
|
||||||
|
violations = append(violations, "runEndpointId is invalid")
|
||||||
|
}
|
||||||
|
if request.TargetKey != "" && !validLogicalFileKey(request.TargetKey) {
|
||||||
|
violations = append(violations, "targetKey is invalid")
|
||||||
|
}
|
||||||
|
if unsafeProductionText(request.Capability) || unsafeProductionText(request.IdempotencyKey) {
|
||||||
|
violations = append(violations, "capacity request contains unsafe content")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateCapacityAdmissionDecision(decision domain.CapacityAdmissionDecision) error {
|
||||||
|
var violations []string
|
||||||
|
if !validCapacityAdmissionState(decision.State) {
|
||||||
|
violations = append(violations, "state is invalid")
|
||||||
|
}
|
||||||
|
violations = appendRequired(violations, "reason", decision.Reason)
|
||||||
|
if len(decision.Reason) > maxProductionMessageLength || unsafeProductionText(decision.Reason) {
|
||||||
|
violations = append(violations, "reason is unsafe")
|
||||||
|
}
|
||||||
|
for i, code := range decision.PressureCodes {
|
||||||
|
if !validCapacityPressureCode(code) {
|
||||||
|
violations = append(violations, fmt.Sprintf("pressureCodes[%d] is invalid", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if decision.RunningJobs < 0 || decision.QueuedJobs < 0 || decision.MaxJobs < 0 {
|
||||||
|
violations = append(violations, "capacity counts must not be negative")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAlertRecord(alert domain.AlertRecord) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", alert.ID)
|
||||||
|
violations = appendRequired(violations, "sourceKind", alert.SourceKind)
|
||||||
|
violations = appendRequired(violations, "sourceId", alert.SourceID)
|
||||||
|
violations = appendRequired(violations, "ruleKey", alert.RuleKey)
|
||||||
|
violations = appendRequired(violations, "title", alert.Title)
|
||||||
|
violations = appendRequired(violations, "message", alert.Message)
|
||||||
|
if !validAlertSeverity(alert.Severity) {
|
||||||
|
violations = append(violations, "severity is invalid")
|
||||||
|
}
|
||||||
|
if !validAlertState(alert.State) {
|
||||||
|
violations = append(violations, "state is invalid")
|
||||||
|
}
|
||||||
|
if alert.OccurrenceCount <= 0 {
|
||||||
|
violations = append(violations, "occurrenceCount must be positive")
|
||||||
|
}
|
||||||
|
for _, value := range []fieldString{{field: "title", value: alert.Title}, {field: "message", value: alert.Message}, {field: "resolutionNote", value: alert.ResolutionNote}} {
|
||||||
|
if len(value.value) > maxProductionMessageLength || unsafeProductionText(value.value) {
|
||||||
|
violations = append(violations, value.field+" is unsafe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAlertAcknowledgeRequest(request domain.AlertAcknowledgeRequest) error {
|
||||||
|
return validateAlertNoteRequest(request.AlertID, request.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAlertResolveRequest(request domain.AlertResolveRequest) error {
|
||||||
|
return validateAlertNoteRequest(request.AlertID, request.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAlertRetryRequest(request domain.AlertRetryRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "alertId", request.AlertID)
|
||||||
|
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||||
|
if unsafeProductionText(request.AlertID) || unsafeProductionText(request.IdempotencyKey) {
|
||||||
|
violations = append(violations, "alert retry request is unsafe")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidatePluginLifecycleInstallation(installation domain.PluginLifecycleInstallation) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", installation.ID)
|
||||||
|
violations = appendRequired(violations, "pluginId", installation.PluginID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", installation.ServerInstanceID)
|
||||||
|
if !validPluginLifecycleState(installation.DesiredState) {
|
||||||
|
violations = append(violations, "desiredState is invalid")
|
||||||
|
}
|
||||||
|
if !validPluginLifecycleState(installation.CurrentState) {
|
||||||
|
violations = append(violations, "currentState is invalid")
|
||||||
|
}
|
||||||
|
if installation.LastOperation != "" && !validPluginLifecycleOperation(installation.LastOperation) {
|
||||||
|
violations = append(violations, "lastOperation is invalid")
|
||||||
|
}
|
||||||
|
for _, value := range []fieldString{{field: "compatibility", value: installation.Compatibility}, {field: "failureReason", value: installation.FailureReason}} {
|
||||||
|
if len(value.value) > maxProductionMessageLength || unsafeProductionText(value.value) {
|
||||||
|
violations = append(violations, value.field+" is unsafe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidatePluginLifecycleRequest(request domain.PluginLifecycleRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "pluginId", request.PluginID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||||
|
if !validPluginLifecycleOperation(request.Operation) {
|
||||||
|
violations = append(violations, "operation is invalid")
|
||||||
|
}
|
||||||
|
if (request.Operation == domain.PluginLifecycleOperationDisable || request.Operation == domain.PluginLifecycleOperationRollback || request.Operation == domain.PluginLifecycleOperationRetire) && !request.Confirmed {
|
||||||
|
violations = append(violations, "confirmed is required for disruptive plugin lifecycle operation")
|
||||||
|
}
|
||||||
|
for _, value := range []fieldString{{field: "targetVersion", value: request.TargetVersion}, {field: "idempotencyKey", value: request.IdempotencyKey}} {
|
||||||
|
if unsafeProductionText(value.value) {
|
||||||
|
violations = append(violations, value.field+" is unsafe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAIConfigDiffPreview(preview domain.AIConfigDiffPreview) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", preview.ID)
|
||||||
|
violations = appendRequired(violations, "requestId", preview.RequestID)
|
||||||
|
violations = appendRequired(violations, "createdBy", preview.CreatedBy)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", preview.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "key", preview.Key)
|
||||||
|
violations = appendRequired(violations, "diffSummary", preview.DiffSummary)
|
||||||
|
if preview.ConfigVersion <= 0 {
|
||||||
|
violations = append(violations, "configVersion must be positive")
|
||||||
|
}
|
||||||
|
if !validAIConfigDiffState(preview.State) {
|
||||||
|
violations = append(violations, "state is invalid")
|
||||||
|
}
|
||||||
|
if !validLogicalFileKey(preview.Key) || !validConfigFileKey(preview.Key) {
|
||||||
|
violations = append(violations, "key is invalid")
|
||||||
|
}
|
||||||
|
if len([]byte(preview.ProposedConfig)) > maxServerConfigContentSize || containsUnsafeRuntimeSecret(preview.ProposedConfig) || looksLikeRawHostPath(preview.ProposedConfig) {
|
||||||
|
violations = append(violations, "proposedConfig is unsafe")
|
||||||
|
}
|
||||||
|
if len(preview.DiffSummary) > maxProductionMessageLength || unsafeProductionText(preview.DiffSummary) {
|
||||||
|
violations = append(violations, "diffSummary is unsafe")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "diffId", request.DiffID)
|
||||||
|
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||||
|
if unsafeProductionText(request.DiffID) || unsafeProductionText(request.IdempotencyKey) {
|
||||||
|
violations = append(violations, "AI config approval request is unsafe")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAlertNoteRequest(alertID string, note string) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "alertId", alertID)
|
||||||
|
if unsafeProductionText(alertID) || len(note) > maxProductionMessageLength || unsafeProductionText(note) {
|
||||||
|
violations = append(violations, "alert note request is unsafe")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCapacityAdmissionState(state domain.CapacityAdmissionState) bool {
|
||||||
|
switch state {
|
||||||
|
case domain.CapacityAdmissionAccepted, domain.CapacityAdmissionDeferred, domain.CapacityAdmissionDenied:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCapacityPressureCode(code domain.CapacityPressureCode) bool {
|
||||||
|
switch code {
|
||||||
|
case domain.CapacityPressureEndpointOffline, domain.CapacityPressureEndpointStale, domain.CapacityPressureCapabilityGap, domain.CapacityPressureJobLimit, domain.CapacityPressureQueueLimit, domain.CapacityPressureBacklog:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAlertSeverity(severity domain.AlertSeverity) bool {
|
||||||
|
switch severity {
|
||||||
|
case domain.AlertSeverityInfo, domain.AlertSeverityWarning, domain.AlertSeverityCritical:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAlertState(state domain.AlertState) bool {
|
||||||
|
switch state {
|
||||||
|
case domain.AlertStateActive, domain.AlertStateAcknowledged, domain.AlertStateResolved:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPluginLifecycleState(state domain.PluginLifecycleState) bool {
|
||||||
|
switch state {
|
||||||
|
case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPluginLifecycleOperation(operation domain.PluginLifecycleOperation) bool {
|
||||||
|
switch operation {
|
||||||
|
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationEnable, domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback, domain.PluginLifecycleOperationRetire, domain.PluginLifecycleOperationDependencyCheck:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAIConfigDiffState(state domain.AIConfigDiffState) bool {
|
||||||
|
switch state {
|
||||||
|
case domain.AIConfigDiffStatePending, domain.AIConfigDiffStateApproved, domain.AIConfigDiffStateCancelled, domain.AIConfigDiffStateExpired:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeProductionText(value string) bool {
|
||||||
|
lower := strings.ToLower(value)
|
||||||
|
return containsUnsafeRuntimeSecret(value) ||
|
||||||
|
looksLikeRawHostPath(value) ||
|
||||||
|
strings.Contains(lower, "api key") ||
|
||||||
|
strings.Contains(lower, "apikey") ||
|
||||||
|
strings.Contains(lower, "provider base url") ||
|
||||||
|
strings.Contains(lower, "direct run") ||
|
||||||
|
strings.Contains(lower, "rcon password") ||
|
||||||
|
strings.Contains(lower, "dsn=")
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
maxServerConfigContentSize = 64 * 1024
|
maxServerConfigContentSize = 64 * 1024
|
||||||
maxJobExecutionContentSize = 64 * 1024
|
maxJobExecutionContentSize = 64 * 1024
|
||||||
maxLogicalFileKeyLength = 160
|
maxLogicalFileKeyLength = 160
|
||||||
|
maxProductionMessageLength = 320
|
||||||
)
|
)
|
||||||
|
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
@@ -146,11 +147,14 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
|||||||
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
||||||
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
|
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
|
||||||
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
||||||
|
violations = append(violations, validateProductionLifecycle("productionLifecycle", plugin.ProductionLifecycle, true)...)
|
||||||
violations = append(violations, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...)
|
violations = append(violations, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...)
|
||||||
if err := ValidateGamePluginRuntimeProfiles(plugin.RuntimeProfiles); err != nil {
|
if err := ValidateGamePluginRuntimeProfiles(plugin.RuntimeProfiles); err != nil {
|
||||||
violations = append(violations, err.Error())
|
violations = append(violations, err.Error())
|
||||||
}
|
}
|
||||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
||||||
|
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
|
||||||
|
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
|
||||||
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
@@ -202,15 +206,262 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
|||||||
violations = append(violations, validatePluginPages(manifest.Pages)...)
|
violations = append(violations, validatePluginPages(manifest.Pages)...)
|
||||||
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
|
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
|
||||||
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
|
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
|
||||||
|
violations = append(violations, validateProductionLifecycle("manifest.productionLifecycle", manifest.ProductionLifecycle, true)...)
|
||||||
|
if containsString(manifest.Permissions, "ai.invoke") || len(manifest.AI.Purposes) > 0 {
|
||||||
|
if manifest.AI.Mediation != "platform" {
|
||||||
|
violations = append(violations, "manifest.ai.mediation must be platform")
|
||||||
|
}
|
||||||
|
if manifest.AI.ConfigWritePolicy != "review-required" {
|
||||||
|
violations = append(violations, "manifest.ai.configWritePolicy must be review-required")
|
||||||
|
}
|
||||||
|
}
|
||||||
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
||||||
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
||||||
violations = append(violations, err.Error())
|
violations = append(violations, err.Error())
|
||||||
}
|
}
|
||||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
||||||
|
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
|
||||||
|
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
|
||||||
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var violations []string
|
||||||
|
if bridge.Retention.KeepForSeconds <= 0 || bridge.Retention.KeepForSeconds > 365*24*60*60 {
|
||||||
|
violations = append(violations, field+".commandRetentionSeconds is invalid")
|
||||||
|
}
|
||||||
|
if bridge.Retention.MaxRecords <= 0 || bridge.Retention.MaxRecords > 100000 {
|
||||||
|
violations = append(violations, field+".maxCommands is invalid")
|
||||||
|
}
|
||||||
|
if companionPresent {
|
||||||
|
prefix := field + ".companion"
|
||||||
|
companion := bridge.Companion
|
||||||
|
if !clientManagerIdentifierPattern.MatchString(companion.ProfileKey) || !clientManagerIdentifierPattern.MatchString(companion.ConfigTemplateKey) {
|
||||||
|
violations = append(violations, prefix+" profile or config template key is invalid")
|
||||||
|
}
|
||||||
|
if !safeRelativeJSONRef(companion.ConfigSchemaRef) {
|
||||||
|
violations = append(violations, prefix+".configSchemaRef must be a safe relative JSON reference")
|
||||||
|
}
|
||||||
|
if companion.ConfigFormat != "yaml" || companion.PlatformBaseURLSource != "run-control" || companion.RegistrationProof != "hmac-sha256" || companion.ProofMaterialSource != "component-package" || companion.SessionMode != "component-session" || companion.TLSPolicy != "verify-system-roots" {
|
||||||
|
violations = append(violations, prefix+" bootstrap security policy is invalid")
|
||||||
|
}
|
||||||
|
if !validCompanionProofEnvironment(companion.ProofMaterialEnv) {
|
||||||
|
violations = append(violations, prefix+".proofMaterialEnv is invalid")
|
||||||
|
}
|
||||||
|
if companion.HeartbeatIntervalSeconds < 5 || companion.HeartbeatIntervalSeconds > 300 || companion.CommandPollIntervalSeconds < 1 || companion.CommandPollIntervalSeconds > 60 || companion.RequestTimeoutSeconds < 1 || companion.RequestTimeoutSeconds > 60 {
|
||||||
|
violations = append(violations, prefix+" timing policy is invalid")
|
||||||
|
}
|
||||||
|
managerFound := false
|
||||||
|
for _, manager := range runtimeProfiles.ClientManagers {
|
||||||
|
if manager.Key != companion.ProfileKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
managerFound = true
|
||||||
|
if manager.Health.IntervalSeconds != companion.HeartbeatIntervalSeconds {
|
||||||
|
violations = append(violations, prefix+".heartbeatIntervalSeconds must match the Client Manager health interval")
|
||||||
|
}
|
||||||
|
for _, capability := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
|
||||||
|
if !containsString(manager.Health.RequiredCapabilities, capability) {
|
||||||
|
violations = append(violations, prefix+" requires Client Manager capability "+capability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templateFound := false
|
||||||
|
for _, template := range manager.ConfigTemplates {
|
||||||
|
if template.Key == companion.ConfigTemplateKey {
|
||||||
|
templateFound = true
|
||||||
|
if template.OutputRef != "config.yaml" {
|
||||||
|
violations = append(violations, prefix+" config template must materialize config.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !templateFound {
|
||||||
|
violations = append(violations, prefix+".configTemplateKey must reference the Client Manager profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !managerFound {
|
||||||
|
violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
commandTypes := map[string]struct{}{}
|
||||||
|
for index, command := range bridge.Commands {
|
||||||
|
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
|
||||||
|
if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) {
|
||||||
|
violations = append(violations, prefix+".type is invalid or unsafe")
|
||||||
|
}
|
||||||
|
if _, exists := commandTypes[command.Type]; exists {
|
||||||
|
violations = append(violations, prefix+".type is duplicated")
|
||||||
|
}
|
||||||
|
commandTypes[command.Type] = struct{}{}
|
||||||
|
if strings.TrimSpace(command.Title) == "" || len([]rune(command.Title)) > 80 {
|
||||||
|
violations = append(violations, prefix+".title is invalid")
|
||||||
|
}
|
||||||
|
if !containsString(permissions, command.Permission) {
|
||||||
|
violations = append(violations, prefix+".permission must be declared by the plugin")
|
||||||
|
}
|
||||||
|
if command.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||||
|
violations = append(violations, prefix+".approvalLevel is invalid")
|
||||||
|
}
|
||||||
|
if !safeRelativeJSONRef(command.PayloadSchemaRef) || command.ResultSchemaRef != "" && !safeRelativeJSONRef(command.ResultSchemaRef) {
|
||||||
|
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
||||||
|
}
|
||||||
|
if command.TimeoutSeconds <= 0 || command.TimeoutSeconds > 3600 {
|
||||||
|
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||||
|
}
|
||||||
|
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
||||||
|
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshotTypes := map[string]struct{}{}
|
||||||
|
for index, snapshot := range bridge.Snapshots {
|
||||||
|
prefix := fmt.Sprintf("%s.snapshots[%d]", field, index)
|
||||||
|
key := snapshot.Type + "\x00" + snapshot.SchemaVersion
|
||||||
|
if !clientManagerIdentifierPattern.MatchString(snapshot.Type) || !clientManagerIdentifierPattern.MatchString(snapshot.SchemaVersion) {
|
||||||
|
violations = append(violations, prefix+" type or schemaVersion is invalid")
|
||||||
|
}
|
||||||
|
if _, exists := snapshotTypes[key]; exists {
|
||||||
|
violations = append(violations, prefix+" type and schemaVersion are duplicated")
|
||||||
|
}
|
||||||
|
snapshotTypes[key] = struct{}{}
|
||||||
|
if !safeRelativeJSONRef(snapshot.SchemaRef) {
|
||||||
|
violations = append(violations, prefix+".schemaRef must be a safe relative JSON reference")
|
||||||
|
}
|
||||||
|
if snapshot.Retention.KeepForSeconds <= 0 || snapshot.Retention.KeepForSeconds > 31*24*60*60 || snapshot.Retention.MaxRecords <= 0 || snapshot.Retention.MaxRecords > 10000 {
|
||||||
|
violations = append(violations, prefix+" retention is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{}
|
||||||
|
transports := map[string]domain.RuntimeTransportProfile{}
|
||||||
|
for _, transport := range runtimeProfiles.TransportProfiles {
|
||||||
|
transports[transport.Key] = transport
|
||||||
|
}
|
||||||
|
for index, template := range bridge.QueryTemplates {
|
||||||
|
prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index)
|
||||||
|
if !clientManagerIdentifierPattern.MatchString(template.Key) {
|
||||||
|
violations = append(violations, prefix+".key is invalid")
|
||||||
|
}
|
||||||
|
if _, exists := queryTemplates[template.Key]; exists {
|
||||||
|
violations = append(violations, prefix+".key is duplicated")
|
||||||
|
}
|
||||||
|
queryTemplates[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.Engine != "sqlite" {
|
||||||
|
violations = append(violations, prefix+".engine must be sqlite")
|
||||||
|
}
|
||||||
|
if !safeRelativeJSONRef(template.ParameterSchemaRef) || !safeRelativeJSONRef(template.ResultSchemaRef) {
|
||||||
|
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
||||||
|
}
|
||||||
|
if template.MaxRows < 1 || template.MaxRows > 500 {
|
||||||
|
violations = append(violations, prefix+".maxRows is invalid")
|
||||||
|
}
|
||||||
|
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
|
||||||
|
violations = append(violations, prefix+".timeoutSeconds 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")
|
||||||
|
}
|
||||||
|
if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||||
|
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageDeclarations := map[string]domain.GamePluginPage{}
|
||||||
|
for _, page := range pages {
|
||||||
|
pageDeclarations[page.Key] = page
|
||||||
|
}
|
||||||
|
for index, page := range bridge.Pages {
|
||||||
|
prefix := fmt.Sprintf("%s.pages[%d]", field, index)
|
||||||
|
pageDeclaration, pageExists := pageDeclarations[page.PageKey]
|
||||||
|
if !pageExists {
|
||||||
|
violations = append(violations, prefix+".pageKey must reference a declared plugin page")
|
||||||
|
}
|
||||||
|
for _, commandType := range page.CommandTypes {
|
||||||
|
if _, exists := commandTypes[commandType]; !exists {
|
||||||
|
violations = append(violations, prefix+" references undeclared command "+commandType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, snapshotType := range page.SnapshotTypes {
|
||||||
|
found := false
|
||||||
|
for key := range snapshotTypes {
|
||||||
|
if strings.HasPrefix(key, snapshotType+"\x00") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
violations = append(violations, prefix+" references undeclared snapshot "+snapshotType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, templateKey := range page.QueryTemplateKeys {
|
||||||
|
template, exists := queryTemplates[templateKey]
|
||||||
|
if !exists {
|
||||||
|
violations = append(violations, prefix+" references undeclared query template "+templateKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !containsString(pageDeclaration.Permissions, template.Permission) {
|
||||||
|
violations = append(violations, prefix+" must declare query template permission "+template.Permission)
|
||||||
|
}
|
||||||
|
if !containsString(pageDeclaration.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) {
|
||||||
|
violations = append(violations, prefix+" must declare remote.access.request for query templates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCompanionProofEnvironment(value string) bool {
|
||||||
|
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value[1:] {
|
||||||
|
if character != '_' && (character < 'A' || character > 'Z') && (character < '0' || character > '9') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reserved := map[string]struct{}{
|
||||||
|
"COMSPEC": {}, "DYLD_INSERT_LIBRARIES": {}, "DYLD_LIBRARY_PATH": {}, "HOME": {}, "LD_LIBRARY_PATH": {}, "LD_PRELOAD": {},
|
||||||
|
"PATH": {}, "PATHEXT": {}, "SHELL": {}, "SYSTEMROOT": {}, "TEMP": {}, "TMP": {}, "USERPROFILE": {}, "WINDIR": {},
|
||||||
|
}
|
||||||
|
if _, exists := reserved[value]; exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeGameClientBridgeCommandType(value string) bool {
|
||||||
|
tokens := gameClientBridgePayloadKeyTokens(value)
|
||||||
|
tokenSet := make(map[string]struct{}, len(tokens))
|
||||||
|
for _, token := range tokens {
|
||||||
|
tokenSet[token] = struct{}{}
|
||||||
|
}
|
||||||
|
has := func(values ...string) bool {
|
||||||
|
for _, candidate := range values {
|
||||||
|
if _, ok := tokenSet[candidate]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if has("sql") || has("database", "db") && has("execute", "exec", "eval", "run", "query", "statement") || has("query") && has("execute", "exec", "eval", "raw", "statement") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
|
||||||
|
}
|
||||||
|
|
||||||
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
|
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
|
||||||
var violations []string
|
var violations []string
|
||||||
violations = appendRequired(violations, "pluginId", request.PluginID)
|
violations = appendRequired(violations, "pluginId", request.PluginID)
|
||||||
@@ -701,12 +952,7 @@ func ValidateRunEndpoint(endpoint domain.RunEndpoint) error {
|
|||||||
if !validRunEndpointStatus(endpoint.Status) {
|
if !validRunEndpointStatus(endpoint.Status) {
|
||||||
violations = append(violations, "status is invalid")
|
violations = append(violations, "status is invalid")
|
||||||
}
|
}
|
||||||
if endpoint.Capacity.MaxJobs < 0 || endpoint.Capacity.RunningJobs < 0 || endpoint.Capacity.QueuedJobs < 0 {
|
violations = appendCapacityViolations(violations, endpoint.Capacity)
|
||||||
violations = append(violations, "capacity counts must not be negative")
|
|
||||||
}
|
|
||||||
if endpoint.Capacity.MaxJobs > 0 && endpoint.Capacity.RunningJobs > endpoint.Capacity.MaxJobs {
|
|
||||||
violations = append(violations, "runningJobs must not exceed maxJobs")
|
|
||||||
}
|
|
||||||
for i, capability := range endpoint.Capabilities {
|
for i, capability := range endpoint.Capabilities {
|
||||||
if strings.TrimSpace(capability) == "" {
|
if strings.TrimSpace(capability) == "" {
|
||||||
violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i))
|
violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i))
|
||||||
@@ -766,6 +1012,7 @@ func ValidateJob(job domain.Job) error {
|
|||||||
if job.ExecutionInput.ExpectedChecksum != "" && !validSHA256Checksum(job.ExecutionInput.ExpectedChecksum) {
|
if job.ExecutionInput.ExpectedChecksum != "" && !validSHA256Checksum(job.ExecutionInput.ExpectedChecksum) {
|
||||||
violations = append(violations, "executionInput.expectedChecksum must be sha256:<hex>")
|
violations = append(violations, "executionInput.expectedChecksum must be sha256:<hex>")
|
||||||
}
|
}
|
||||||
|
violations = append(violations, validateRemoteAdapterInputs("executionInput.inputs", job.ExecutionInput.Inputs)...)
|
||||||
if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) {
|
if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) {
|
||||||
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
|
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
|
||||||
}
|
}
|
||||||
@@ -1065,6 +1312,9 @@ func pluginSafeStrings(plugin domain.GamePlugin) []fieldString {
|
|||||||
values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions)
|
values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions)
|
||||||
values = appendStringSliceFields(values, "tags", plugin.Tags)
|
values = appendStringSliceFields(values, "tags", plugin.Tags)
|
||||||
values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes)
|
values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes)
|
||||||
|
values = appendStringSliceFields(values, "productionLifecycle.operations", plugin.ProductionLifecycle.Operations)
|
||||||
|
values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", plugin.ProductionLifecycle.ApprovalRequired)
|
||||||
|
values = append(values, fieldString{field: "productionLifecycle.dependencyPolicy", value: plugin.ProductionLifecycle.DependencyPolicy})
|
||||||
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
||||||
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
||||||
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
||||||
@@ -1105,6 +1355,9 @@ func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []f
|
|||||||
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
||||||
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
||||||
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
||||||
|
values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "ai.configWritePolicy", value: manifest.AI.ConfigWritePolicy}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy})
|
||||||
|
values = appendStringSliceFields(values, "productionLifecycle.operations", manifest.ProductionLifecycle.Operations)
|
||||||
|
values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", manifest.ProductionLifecycle.ApprovalRequired)
|
||||||
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
||||||
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
||||||
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines)
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines)
|
||||||
@@ -1407,7 +1660,7 @@ func validScopedInputRef(ref string) bool {
|
|||||||
|
|
||||||
func validPluginPermission(permission string) bool {
|
func validPluginPermission(permission string) bool {
|
||||||
switch permission {
|
switch permission {
|
||||||
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage", "ai.invoke":
|
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -1426,6 +1679,7 @@ func validPluginBridgeAction(action domain.PluginBridgeAction) bool {
|
|||||||
domain.PluginBridgeActionDependenciesRequest,
|
domain.PluginBridgeActionDependenciesRequest,
|
||||||
domain.PluginBridgeActionLogsBackfillRequest,
|
domain.PluginBridgeActionLogsBackfillRequest,
|
||||||
domain.PluginBridgeActionClientManager,
|
domain.PluginBridgeActionClientManager,
|
||||||
|
domain.PluginBridgeActionPluginLifecycle,
|
||||||
domain.PluginBridgeActionAIInvoke:
|
domain.PluginBridgeActionAIInvoke:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
@@ -1455,6 +1709,8 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
|||||||
return []string{"server.logs.read"}
|
return []string{"server.logs.read"}
|
||||||
case domain.PluginBridgeActionClientManager:
|
case domain.PluginBridgeActionClientManager:
|
||||||
return []string{"server.client-manager.manage"}
|
return []string{"server.client-manager.manage"}
|
||||||
|
case domain.PluginBridgeActionPluginLifecycle:
|
||||||
|
return []string{"server.lifecycle"}
|
||||||
case domain.PluginBridgeActionAIInvoke:
|
case domain.PluginBridgeActionAIInvoke:
|
||||||
return []string{"ai.invoke"}
|
return []string{"ai.invoke"}
|
||||||
default:
|
default:
|
||||||
@@ -1462,6 +1718,35 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateProductionLifecycle(field string, lifecycle domain.GamePluginProductionLifecycle, required bool) []string {
|
||||||
|
var violations []string
|
||||||
|
if required && len(lifecycle.Operations) == 0 {
|
||||||
|
violations = append(violations, field+".operations must not be empty")
|
||||||
|
}
|
||||||
|
for i, operation := range lifecycle.Operations {
|
||||||
|
switch domain.PluginLifecycleOperation(operation) {
|
||||||
|
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationEnable, domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback, domain.PluginLifecycleOperationRetire, domain.PluginLifecycleOperationDependencyCheck:
|
||||||
|
default:
|
||||||
|
violations = append(violations, fmt.Sprintf("%s.operations[%d] is invalid", field, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
violations = append(violations, duplicateViolations(field+".operations", lifecycle.Operations)...)
|
||||||
|
if lifecycle.DependencyPolicy != "required" && lifecycle.DependencyPolicy != "optional" {
|
||||||
|
violations = append(violations, field+".dependencyPolicy must be required or optional")
|
||||||
|
}
|
||||||
|
for i, operation := range lifecycle.ApprovalRequired {
|
||||||
|
if operation != string(domain.PluginLifecycleOperationDisable) && operation != string(domain.PluginLifecycleOperationRollback) && operation != string(domain.PluginLifecycleOperationRetire) {
|
||||||
|
violations = append(violations, fmt.Sprintf("%s.approvalRequired[%d] is invalid", field, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, operation := range []string{string(domain.PluginLifecycleOperationDisable), string(domain.PluginLifecycleOperationRollback), string(domain.PluginLifecycleOperationRetire)} {
|
||||||
|
if containsString(lifecycle.Operations, operation) && !containsString(lifecycle.ApprovalRequired, operation) {
|
||||||
|
violations = append(violations, field+".approvalRequired must include "+operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
func effectivePagePermissions(plugin domain.GamePlugin, routeKey string) []string {
|
func effectivePagePermissions(plugin domain.GamePlugin, routeKey string) []string {
|
||||||
declared := plugin.DeclaredPermissions
|
declared := plugin.DeclaredPermissions
|
||||||
page, found := findPluginPage(plugin.Pages, routeKey)
|
page, found := findPluginPage(plugin.Pages, routeKey)
|
||||||
|
|||||||
@@ -72,6 +72,106 @@ 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.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.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"}}},
|
||||||
|
}
|
||||||
|
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||||
|
t.Fatalf("expected bridge catalog to validate, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, commandType := range []string{"sql.execute", "sqlExecute", "database.execute", "database.query", "shell.execute", "powershell.execute", "script.run", "terminal.execute", "command.run"} {
|
||||||
|
t.Run("unsafe command type "+commandType, func(t *testing.T) {
|
||||||
|
unsafeType := registration
|
||||||
|
unsafeType.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge)
|
||||||
|
unsafeType.Manifest.GameClientBridge.Commands[0].Type = commandType
|
||||||
|
err := ValidateGamePluginManifestRegistration(unsafeType)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "type is invalid or unsafe") {
|
||||||
|
t.Fatalf("expected %q to be rejected, got %v", commandType, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe := registration
|
||||||
|
unsafe.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge)
|
||||||
|
unsafe.Manifest.GameClientBridge.Commands[0].Type = "shell.execute"
|
||||||
|
unsafe.Manifest.GameClientBridge.Commands[0].ApprovalLevel = ""
|
||||||
|
unsafe.Manifest.GameClientBridge.Commands[0].PayloadSchemaRef = "/etc/command.json"
|
||||||
|
unsafe.Manifest.GameClientBridge.Pages[0].CommandTypes = []string{"undeclared.command"}
|
||||||
|
err := ValidateGamePluginManifestRegistration(unsafe)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unsafe bridge catalog rejection")
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"type is invalid or unsafe", "approvalLevel is invalid", "schema references", "undeclared command"} {
|
||||||
|
if !strings.Contains(err.Error(), expected) {
|
||||||
|
t.Fatalf("expected %q in validation error: %v", expected, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryTemplateTests := []struct {
|
||||||
|
name string
|
||||||
|
expected string
|
||||||
|
mutate func(*domain.GamePluginManifestRegistration)
|
||||||
|
}{
|
||||||
|
{name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.QueryTemplates = append(value.Manifest.GameClientBridge.QueryTemplates, value.Manifest.GameClientBridge.QueryTemplates[0])
|
||||||
|
}},
|
||||||
|
{name: "unsupported engine", expected: "engine must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.QueryTemplates[0].Engine = "mysql"
|
||||||
|
}},
|
||||||
|
{name: "undeclared permission", expected: "permission must be declared", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.QueryTemplates[0].Permission = "server.database.admin"
|
||||||
|
}},
|
||||||
|
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.QueryTemplates[0].ParameterSchemaRef = "/etc/query.json"
|
||||||
|
}},
|
||||||
|
{name: "row bound", expected: "maxRows is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.QueryTemplates[0].MaxRows = 501
|
||||||
|
}},
|
||||||
|
{name: "timeout bound", expected: "timeoutSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.QueryTemplates[0].TimeoutSeconds = 61
|
||||||
|
}},
|
||||||
|
{name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing"
|
||||||
|
}},
|
||||||
|
{name: "target mismatch", expected: "targetKey must match", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.QueryTemplates[0].TargetKey = "db/other"
|
||||||
|
}},
|
||||||
|
{name: "missing sqlite capability", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.RuntimeProfiles.TransportProfiles[0].Capabilities = []string{"files.read"}
|
||||||
|
}},
|
||||||
|
{name: "undeclared page template", expected: "undeclared query template", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.GameClientBridge.Pages[0].QueryTemplateKeys = []string{"missing.lookup"}
|
||||||
|
}},
|
||||||
|
{name: "page missing template permission", expected: "must declare query template permission", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.Pages[0].Permissions = []string{"server.logs.read", "server.remote.access"}
|
||||||
|
}},
|
||||||
|
{name: "page missing remote action", expected: "must declare remote.access.request", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
|
value.Manifest.Pages[0].BridgeActions = nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range queryTemplateTests {
|
||||||
|
t.Run("query 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) {
|
func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) {
|
||||||
registration := validGamePluginManifestRegistration()
|
registration := validGamePluginManifestRegistration()
|
||||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket")
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket")
|
||||||
@@ -235,7 +335,8 @@ func validGamePluginManifestRegistration() domain.GamePluginManifestRegistration
|
|||||||
Pages: []domain.GamePluginPage{
|
Pages: []domain.GamePluginPage{
|
||||||
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
||||||
},
|
},
|
||||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
|
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||||
|
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,6 @@
|
|||||||
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress/audit summaries, artifact metadata, log stream cursors, and run capability compatibility.
|
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress/audit summaries, artifact metadata, log stream cursors, and run capability compatibility.
|
||||||
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
|
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
|
||||||
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
||||||
|
# Client Manager lifecycle validation
|
||||||
|
|
||||||
|
Lifecycle validation rejects undeclared operations, stale attempt/deployment/key generations, cross-owner/server/profile/target/revision artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Registration additionally requires a current component-key HMAC, fresh nonce/timestamp, matching artifact and capabilities, and a monotonic heartbeat sequence. Safe DTOs are redacted before they cross the Platform boundary.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validRuntimeLogEventProfiles() domain.GamePluginRuntimeProfiles {
|
||||||
|
return domain.GamePluginRuntimeProfiles{
|
||||||
|
LogSources: []domain.RuntimeLogSource{{Key: "chat-log", Kind: "file.tail", StreamKey: "chat", CursorKind: "offset", RetentionDays: 30}},
|
||||||
|
LogEvents: []domain.RuntimeLogEvent{{
|
||||||
|
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||||
|
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGamePluginRuntimeProfilesValidatesLogEvents(t *testing.T) {
|
||||||
|
if err := ValidateGamePluginRuntimeProfiles(validRuntimeLogEventProfiles()); err != nil {
|
||||||
|
t.Fatalf("expected valid runtime log event declaration, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expected string
|
||||||
|
mutate func(*domain.GamePluginRuntimeProfiles)
|
||||||
|
}{
|
||||||
|
{name: "undeclared source", expected: "sourceKey must reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].SourceKey = "missing" }},
|
||||||
|
{name: "invalid event type", expected: "eventType is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].EventType = "chat message" }},
|
||||||
|
{name: "invalid permission", expected: "permission is not allowed", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Permission = "server.admin" }},
|
||||||
|
{name: "unsafe schema", expected: "schemaRef must be a bounded safe relative JSON reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||||
|
profiles.LogEvents[0].SchemaRef = "schemas/log events/chat.json"
|
||||||
|
}},
|
||||||
|
{name: "retention bound", expected: "retentionDays is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 366 }},
|
||||||
|
{name: "retention exceeds source", expected: "retentionDays must not exceed the source retention", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 31 }},
|
||||||
|
{name: "invalid severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "emergency" }},
|
||||||
|
{name: "plugin error severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "error" }},
|
||||||
|
{name: "duplicate key", expected: "key is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||||
|
profiles.LogEvents = append(profiles.LogEvents, profiles.LogEvents[0])
|
||||||
|
}},
|
||||||
|
{name: "duplicate event type", expected: "eventType is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||||
|
duplicate := profiles.LogEvents[0]
|
||||||
|
duplicate.Key = "chat-message-copy"
|
||||||
|
profiles.LogEvents = append(profiles.LogEvents, duplicate)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
profiles := domain.CopyGamePluginRuntimeProfiles(validRuntimeLogEventProfiles())
|
||||||
|
test.mutate(&profiles)
|
||||||
|
err := ValidateGamePluginRuntimeProfiles(profiles)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
||||||
|
t.Fatalf("expected %q validation error, got %v", test.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGamePluginRuntimeProfilesAcceptsDeclaredLogEventSeverities(t *testing.T) {
|
||||||
|
for _, severity := range []domain.RuntimeLogEventSeverity{
|
||||||
|
domain.RuntimeLogEventSeverityInfo,
|
||||||
|
domain.RuntimeLogEventSeverityNotice,
|
||||||
|
domain.RuntimeLogEventSeverityWarning,
|
||||||
|
domain.RuntimeLogEventSeverityCritical,
|
||||||
|
} {
|
||||||
|
t.Run(string(severity), func(t *testing.T) {
|
||||||
|
profiles := validRuntimeLogEventProfiles()
|
||||||
|
profiles.LogEvents[0].Severity = severity
|
||||||
|
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||||
|
t.Fatalf("expected severity %q to validate, got %v", severity, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGamePluginRuntimeProfilesAllowsEventRetentionWhenSourceUsesDefault(t *testing.T) {
|
||||||
|
profiles := validRuntimeLogEventProfiles()
|
||||||
|
profiles.LogSources[0].RetentionDays = 0
|
||||||
|
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||||
|
t.Fatalf("expected source default retention to allow bounded event retention, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *testing.T) {
|
||||||
|
unsafeEventTypes := []string{
|
||||||
|
"ops.shell.execute",
|
||||||
|
"ops.execute",
|
||||||
|
"audit.sql.query",
|
||||||
|
"audit.raw-host-path",
|
||||||
|
"run.socket.open",
|
||||||
|
"auth.credential.exposed",
|
||||||
|
"auth.api-key.exposed",
|
||||||
|
}
|
||||||
|
for _, eventType := range unsafeEventTypes {
|
||||||
|
t.Run(eventType, func(t *testing.T) {
|
||||||
|
profiles := validRuntimeLogEventProfiles()
|
||||||
|
profiles.LogEvents[0].EventType = eventType
|
||||||
|
err := ValidateGamePluginRuntimeProfiles(profiles)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "eventType contains unsafe operation semantics") {
|
||||||
|
t.Fatalf("expected unsafe event type %q to be rejected, got %v", eventType, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGamePluginManifestRegistrationRequiresDeclaredLogEventPermission(t *testing.T) {
|
||||||
|
registration := validGamePluginManifestRegistration()
|
||||||
|
registration.Manifest.RuntimeProfiles = validRuntimeLogEventProfiles()
|
||||||
|
registration.Manifest.RuntimeProfiles.LogEvents[0].Permission = "server.game-client.read"
|
||||||
|
|
||||||
|
err := ValidateGamePluginManifestRegistration(registration)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "manifest.runtimeProfiles.logEvents[0].permission must be declared by the plugin") {
|
||||||
|
t.Fatalf("expected undeclared log event permission rejection, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read")
|
||||||
|
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||||
|
t.Fatalf("expected declared log event permission to validate, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ import (
|
|||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
runtimeLogEventSchemaRefPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]+\.json$`)
|
||||||
|
runtimeLogEventTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,119}$`)
|
||||||
|
)
|
||||||
|
|
||||||
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
||||||
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
|
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
|
||||||
var violations []string
|
var violations []string
|
||||||
@@ -21,6 +26,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
|||||||
dependencyKeys := map[string]struct{}{}
|
dependencyKeys := map[string]struct{}{}
|
||||||
installPlanKeys := map[string]struct{}{}
|
installPlanKeys := map[string]struct{}{}
|
||||||
logSourceKeys := map[string]struct{}{}
|
logSourceKeys := map[string]struct{}{}
|
||||||
|
logSourceRetentions := map[string]int{}
|
||||||
|
logEventKeys := map[string]struct{}{}
|
||||||
|
logEventTypes := map[string]struct{}{}
|
||||||
|
|
||||||
for i, probe := range profiles.Discovery {
|
for i, probe := range profiles.Discovery {
|
||||||
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
|
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
|
||||||
@@ -138,6 +146,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
|||||||
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
|
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
|
||||||
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
||||||
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
|
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
|
||||||
|
if source.Key != "" {
|
||||||
|
logSourceRetentions[source.Key] = source.RetentionDays
|
||||||
|
}
|
||||||
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
|
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
|
||||||
violations = append(violations, prefix+".kind is invalid")
|
violations = append(violations, prefix+".kind is invalid")
|
||||||
}
|
}
|
||||||
@@ -152,6 +163,44 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
|||||||
violations = append(violations, prefix+".retentionDays is invalid")
|
violations = append(violations, prefix+".retentionDays is invalid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(profiles.LogEvents) > 128 {
|
||||||
|
violations = append(violations, "runtimeProfiles.logEvents must not exceed 128")
|
||||||
|
}
|
||||||
|
for i, event := range profiles.LogEvents {
|
||||||
|
prefix := fmt.Sprintf("runtimeProfiles.logEvents[%d]", i)
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".key", event.Key)...)
|
||||||
|
violations = append(violations, recordRuntimeProfileKey(logEventKeys, prefix+".key", event.Key)...)
|
||||||
|
if strings.TrimSpace(event.Title) == "" || len([]rune(event.Title)) > 80 {
|
||||||
|
violations = append(violations, prefix+".title is invalid")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateSafeRuntimeValue(prefix+".title", event.Title)...)
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".sourceKey", event.SourceKey)...)
|
||||||
|
if _, exists := logSourceKeys[event.SourceKey]; !exists {
|
||||||
|
violations = append(violations, prefix+".sourceKey must reference a declared runtime log source")
|
||||||
|
}
|
||||||
|
if !runtimeLogEventTypePattern.MatchString(event.EventType) {
|
||||||
|
violations = append(violations, prefix+".eventType is invalid")
|
||||||
|
}
|
||||||
|
violations = append(violations, recordRuntimeProfileKey(logEventTypes, prefix+".eventType", event.EventType)...)
|
||||||
|
if hasUnsafeRuntimeLogEventSemantics(event.EventType) {
|
||||||
|
violations = append(violations, prefix+".eventType contains unsafe operation semantics")
|
||||||
|
}
|
||||||
|
if !validPluginPermission(event.Permission) {
|
||||||
|
violations = append(violations, prefix+".permission is not allowed")
|
||||||
|
}
|
||||||
|
if len(event.SchemaRef) > 240 || !runtimeLogEventSchemaRefPattern.MatchString(event.SchemaRef) || !safeRelativeJSONRef(event.SchemaRef) {
|
||||||
|
violations = append(violations, prefix+".schemaRef must be a bounded safe relative JSON reference")
|
||||||
|
}
|
||||||
|
if event.RetentionDays < 1 || event.RetentionDays > 365 {
|
||||||
|
violations = append(violations, prefix+".retentionDays is invalid")
|
||||||
|
}
|
||||||
|
if sourceRetention, exists := logSourceRetentions[event.SourceKey]; exists && sourceRetention > 0 && event.RetentionDays > sourceRetention {
|
||||||
|
violations = append(violations, prefix+".retentionDays must not exceed the source retention")
|
||||||
|
}
|
||||||
|
if !oneOf(string(event.Severity), string(domain.RuntimeLogEventSeverityInfo), string(domain.RuntimeLogEventSeverityNotice), string(domain.RuntimeLogEventSeverityWarning), string(domain.RuntimeLogEventSeverityCritical)) {
|
||||||
|
violations = append(violations, prefix+".severity is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
for i, transport := range profiles.TransportProfiles {
|
for i, transport := range profiles.TransportProfiles {
|
||||||
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
||||||
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
||||||
@@ -346,6 +395,42 @@ func validateSafeRuntimeValue(field, value string) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasUnsafeRuntimeLogEventSemantics(eventType string) bool {
|
||||||
|
lowered := strings.ToLower(strings.TrimSpace(eventType))
|
||||||
|
tokens := strings.FieldsFunc(lowered, func(char rune) bool {
|
||||||
|
return char == '.' || char == '_' || char == '-' || char == '/'
|
||||||
|
})
|
||||||
|
unsafeTokens := map[string]struct{}{
|
||||||
|
"apikey": {}, "credential": {}, "credentials": {}, "eval": {}, "exec": {},
|
||||||
|
"execute": {}, "password": {}, "powershell": {}, "script": {}, "secret": {},
|
||||||
|
"shell": {}, "socket": {}, "terminal": {}, "token": {},
|
||||||
|
}
|
||||||
|
for _, token := range tokens {
|
||||||
|
if _, unsafe := unsafeTokens[token]; unsafe {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := 0; index+1 < len(tokens); index++ {
|
||||||
|
pair := tokens[index] + "." + tokens[index+1]
|
||||||
|
switch pair {
|
||||||
|
case "absolute.path", "access.key", "api.key", "component.key", "database.query", "direct.socket", "file.path", "host.path", "private.key", "raw.path", "run.direct", "run.socket", "unix.socket":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokenSet := make(map[string]struct{}, len(tokens))
|
||||||
|
for _, token := range tokens {
|
||||||
|
tokenSet[token] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, hasSQL := tokenSet["sql"]; hasSQL {
|
||||||
|
for _, token := range []string{"query", "statement", "raw"} {
|
||||||
|
if _, unsafe := tokenSet[token]; unsafe {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
|
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -382,6 +467,20 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRuntimeLogEventPermissionDeclarations(field string, profiles domain.GamePluginRuntimeProfiles, declared []string) []string {
|
||||||
|
declaredSet := make(map[string]struct{}, len(declared))
|
||||||
|
for _, permission := range declared {
|
||||||
|
declaredSet[permission] = struct{}{}
|
||||||
|
}
|
||||||
|
var violations []string
|
||||||
|
for i, event := range profiles.LogEvents {
|
||||||
|
if _, exists := declaredSet[event.Permission]; !exists {
|
||||||
|
violations = append(violations, fmt.Sprintf("%s[%d].permission must be declared by the plugin", field, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
|
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
|
||||||
var violations []string
|
var violations []string
|
||||||
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
|
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
|
||||||
|
|||||||
@@ -105,3 +105,8 @@ LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/priv
|
|||||||
```
|
```
|
||||||
|
|
||||||
This command verifies the API-backed local debug console path, first-party route markers, plugin/server operation proof, fallback rejection, and forbidden-fragment scans. It writes evidence under `<LOCAL_DEBUG_ROOT>/browser-acceptance/`.
|
This command verifies the API-backed local debug console path, first-party route markers, plugin/server operation proof, fallback rejection, and forbidden-fragment scans. It writes evidence under `<LOCAL_DEBUG_ROOT>/browser-acceptance/`.
|
||||||
|
# Client Manager workspace
|
||||||
|
|
||||||
|
Server Detail includes a Client Manager lifecycle workspace backed by the typed Platform installation projection. It shows profile/target, desired-active-previous versions and revisions, artifact/checksum metadata, key/deployment generations, registration/health/last-seen, real job phases/progress, action gating reasons, retry guidance, and confirmed deploy/control/update/rollback/revoke/key-reset/uninstall workflows. The browser polls active jobs and never fabricates later phases.
|
||||||
|
|
||||||
|
The UI keeps the black-mecha and magical-girl crystal-moonlight console materials and uses shared panel/command tokens. It renders no raw key, token, secret ref/value, host path, PID, socket, credential, DSN, RCON password, or Run endpoint address. 401/403 responses remain platform auth/capability errors, not local fallback success.
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ const forbiddenFragments = [
|
|||||||
{ name: "tcp socket", pattern: /tcp:\/\// },
|
{ name: "tcp socket", pattern: /tcp:\/\// },
|
||||||
{ name: "bearer token", pattern: /Bearer\s+/ },
|
{ name: "bearer token", pattern: /Bearer\s+/ },
|
||||||
{ name: "raw api key", pattern: /sk-[A-Za-z0-9_-]+/ },
|
{ name: "raw api key", pattern: /sk-[A-Za-z0-9_-]+/ },
|
||||||
{ name: "password query", pattern: /password=/ },
|
{ name: "password query", pattern: /password\s*=/i },
|
||||||
|
{ name: "credential value", pattern: /credential\s*[:=]\s*\S+/i },
|
||||||
|
{ name: "DSN value", pattern: /\b(?:postgres(?:ql)?|mysql|redis|mongodb(?:\+srv)?):\/\//i },
|
||||||
|
{ name: "RCON value", pattern: /\brcon(?:[_-]?(?:password|token))?\s*[:=]\s*\S+/i },
|
||||||
|
{ name: "host path", pattern: /\/(?:home|var|etc|opt|root|tmp)\/|[A-Za-z]:\\/ },
|
||||||
|
{ name: "PID value", pattern: /\bpid\s*[:=#]?\s*\d+/i },
|
||||||
{ name: "raw api key ref field", pattern: /apiKeyRef/ },
|
{ name: "raw api key ref field", pattern: /apiKeyRef/ },
|
||||||
{ name: "raw api key field", pattern: /rawApiKey/ },
|
{ name: "raw api key field", pattern: /rawApiKey/ },
|
||||||
{ name: "session token", pattern: /sessionToken|run session token/i },
|
{ name: "session token", pattern: /sessionToken|run session token/i },
|
||||||
@@ -67,8 +72,17 @@ async function main() {
|
|||||||
assertIncludes(plugin.requiredRunCapabilities, "process.start", "plugin requires process.start");
|
assertIncludes(plugin.requiredRunCapabilities, "process.start", "plugin requires process.start");
|
||||||
assertIncludes(plugin.bridgeActions, "logs.query", "plugin exposes logs.query bridge action");
|
assertIncludes(plugin.bridgeActions, "logs.query", "plugin exposes logs.query bridge action");
|
||||||
assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action");
|
assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action");
|
||||||
|
assertIncludes(plugin.bridgeActions, "plugin-lifecycle.request", "plugin exposes Platform lifecycle bridge action");
|
||||||
|
assertIncludes(plugin.bridgeActions, "ai.invoke", "plugin exposes Platform AI bridge action");
|
||||||
|
assertIncludes(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback governance");
|
||||||
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
|
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
|
||||||
assertSafeRedactedRef(aiProvider.apiKeyRef, "AI provider key reference");
|
assertEqual(aiProvider.apiKeyConfigured, true, "AI provider key presence projection");
|
||||||
|
assertEqual(aiProvider.baseUrlConfigured, true, "AI provider base URL presence projection");
|
||||||
|
if (Object.hasOwn(aiProvider, "baseUrl") || Object.hasOwn(aiProvider, "apiKeyRef")) {
|
||||||
|
throw new Error("AI provider response exposed Platform-owned endpoint or secret reference");
|
||||||
|
}
|
||||||
|
|
||||||
|
const productionSeed = await prepareProductionOperations(authHeaders, server, plugin);
|
||||||
|
|
||||||
const chrome = await startChrome();
|
const chrome = await startChrome();
|
||||||
const evidence = {
|
const evidence = {
|
||||||
@@ -90,11 +104,12 @@ async function main() {
|
|||||||
plugin: pick(plugin, ["id", "name", "version", "status", "manifestRef", "requiredRunCapabilities", "bridgeActions", "declaredPermissions"]),
|
plugin: pick(plugin, ["id", "name", "version", "status", "manifestRef", "requiredRunCapabilities", "bridgeActions", "declaredPermissions"]),
|
||||||
marketplacePlugin: pick(marketplacePlugin, ["id", "name", "version", "status", "manifestRef", "capabilities", "bridgeActions", "declaredPermissions"]),
|
marketplacePlugin: pick(marketplacePlugin, ["id", "name", "version", "status", "manifestRef", "capabilities", "bridgeActions", "declaredPermissions"]),
|
||||||
operator: pick(operator, ["id", "displayName", "email", "status", "roles"]),
|
operator: pick(operator, ["id", "displayName", "email", "status", "roles"]),
|
||||||
aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "apiKeyRef"]),
|
aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "baseUrlConfigured", "apiKeyConfigured"]),
|
||||||
jobs: jobs.items.map((job) => pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"])),
|
jobs: jobs.items.map((job) => pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"])),
|
||||||
logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])),
|
logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])),
|
||||||
artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])),
|
artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])),
|
||||||
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"])
|
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"]),
|
||||||
|
production: productionSeed.apiProof
|
||||||
},
|
},
|
||||||
routes: [],
|
routes: [],
|
||||||
safety: {
|
safety: {
|
||||||
@@ -112,7 +127,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
name: "首页",
|
name: "首页",
|
||||||
hash: "#/home",
|
hash: "#/home",
|
||||||
markers: ["平台概览", "数据已加载", "game.example", "运行节点", "CPU"]
|
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU", "生产容量与告警", "运行槽位"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "服务器管理",
|
name: "服务器管理",
|
||||||
@@ -145,7 +160,12 @@ async function main() {
|
|||||||
{
|
{
|
||||||
name: "AI 提供商管理",
|
name: "AI 提供商管理",
|
||||||
hash: "#/aiProviders",
|
hash: "#/aiProviders",
|
||||||
markers: ["AI 提供商管理", "平台 API", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
|
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", productionSeed.diff.diffSummary, server.id]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "系统维护",
|
||||||
|
hash: "#/maintenance",
|
||||||
|
markers: ["系统维护", "容量治理与告警闭环", "运行槽位", productionSeed.alert.title]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "服务器详情",
|
name: "服务器详情",
|
||||||
@@ -181,9 +201,15 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
||||||
evidence.routes.push({ name: "服务器详情 / 插件控制", url: await chrome.url(), ...pluginControls });
|
evidence.routes.push({ name: "服务器详情 / 插件控制", url: await chrome.url(), ...pluginControls });
|
||||||
|
|
||||||
|
evidence.productionInteractions = {
|
||||||
|
alert: await verifyAlertInteraction(chrome, authHeaders, productionSeed.alert),
|
||||||
|
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
|
||||||
|
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, productionSeed.diff)
|
||||||
|
};
|
||||||
|
|
||||||
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
|
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
|
||||||
|
|
||||||
const operationProof = await verifyLifecycleOperation(authHeaders, server, chrome);
|
const operationProof = await verifyLifecycleOperation(authHeaders, server, chrome);
|
||||||
@@ -229,12 +255,12 @@ async function loginInBrowser(chrome) {
|
|||||||
setInputValue(password, "operator-local");
|
setInputValue(password, "operator-local");
|
||||||
submit.click();
|
submit.click();
|
||||||
});
|
});
|
||||||
await chrome.waitForText(["平台概览", "数据已加载"], "post-login home");
|
await chrome.waitForText(["平台概览", "运营数据已同步"], "post-login home");
|
||||||
const visibleText = await chrome.visibleText();
|
const visibleText = await chrome.visibleText();
|
||||||
scanText(visibleText, "登录后首页");
|
scanText(visibleText, "登录后首页");
|
||||||
return {
|
return {
|
||||||
url: await chrome.url(),
|
url: await chrome.url(),
|
||||||
requiredMarkers: ["平台概览", "数据已加载"],
|
requiredMarkers: ["平台概览", "运营数据已同步"],
|
||||||
fallbackScan: "passed",
|
fallbackScan: "passed",
|
||||||
forbiddenFragmentScan: "passed"
|
forbiddenFragmentScan: "passed"
|
||||||
};
|
};
|
||||||
@@ -294,9 +320,21 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
|
|||||||
for (const scenario of scenarios) {
|
for (const scenario of scenarios) {
|
||||||
await chrome.setViewport(scenario.viewport);
|
await chrome.setViewport(scenario.viewport);
|
||||||
await applyBrowserTheme(chrome, scenario.paletteId, scenario.backgroundId);
|
await applyBrowserTheme(chrome, scenario.paletteId, scenario.backgroundId);
|
||||||
|
const appliedPalette = await chrome.evaluate(() => document.documentElement.dataset.themePalette || "");
|
||||||
|
assertEqual(appliedPalette, scenario.paletteId, `${scenario.name} root theme marker`);
|
||||||
const routeEvidence = [];
|
const routeEvidence = [];
|
||||||
|
if (scenario.viewport.mobile) {
|
||||||
|
const mobileNavigation = await verifyMobileNavigation(chrome, scenario.name, scenario.themeMarkers);
|
||||||
|
routeEvidence.push({
|
||||||
|
name: "移动导航抽屉",
|
||||||
|
url: await chrome.url(),
|
||||||
|
...mobileNavigation,
|
||||||
|
layout: await chrome.layoutSnapshot()
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const route of routeChecks) {
|
for (const route of routeChecks) {
|
||||||
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...scenario.themeMarkers], `${scenario.name} / ${route.name}`);
|
const routeThemeMarkers = scenario.viewport.mobile ? [] : scenario.themeMarkers;
|
||||||
|
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...routeThemeMarkers], `${scenario.name} / ${route.name}`);
|
||||||
const layout = await chrome.layoutSnapshot();
|
const layout = await chrome.layoutSnapshot();
|
||||||
assertNoVisibleLayoutIssues(layout, `${scenario.name} / ${route.name}`);
|
assertNoVisibleLayoutIssues(layout, `${scenario.name} / ${route.name}`);
|
||||||
routeEvidence.push({
|
routeEvidence.push({
|
||||||
@@ -328,7 +366,7 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
|
|||||||
window.location.hash = "#/servers/server-local-debug";
|
window.location.hash = "#/servers/server-local-debug";
|
||||||
});
|
});
|
||||||
await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`);
|
await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`);
|
||||||
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
||||||
const pluginLayout = await chrome.layoutSnapshot();
|
const pluginLayout = await chrome.layoutSnapshot();
|
||||||
assertNoVisibleLayoutIssues(pluginLayout, `${scenario.name} / plugin controls`);
|
assertNoVisibleLayoutIssues(pluginLayout, `${scenario.name} / plugin controls`);
|
||||||
routeEvidence.push({
|
routeEvidence.push({
|
||||||
@@ -402,15 +440,11 @@ async function clickAndVerify(chrome, buttonText, markers) {
|
|||||||
async function verifyServerQuickRuntimeMenu(chrome, label) {
|
async function verifyServerQuickRuntimeMenu(chrome, label) {
|
||||||
const markers = ["生成 run", "下载 run", "推送更新", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
|
const markers = ["生成 run", "下载 run", "推送更新", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
|
||||||
await chrome.evaluate(() => {
|
await chrome.evaluate(() => {
|
||||||
const summary = Array.from(document.querySelectorAll("summary")).find((item) => item.textContent?.includes("运行操作"));
|
const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作"));
|
||||||
if (!(summary instanceof HTMLElement)) {
|
if (!(trigger instanceof HTMLButtonElement)) {
|
||||||
throw new Error("runtime action menu summary not found");
|
throw new Error("runtime action menu trigger not found");
|
||||||
}
|
}
|
||||||
const details = summary.closest("details");
|
trigger.click();
|
||||||
if (!(details instanceof HTMLDetailsElement)) {
|
|
||||||
throw new Error("runtime action menu container not found");
|
|
||||||
}
|
|
||||||
details.open = true;
|
|
||||||
});
|
});
|
||||||
await chrome.waitForText(markers, `${label} runtime action menu`);
|
await chrome.waitForText(markers, `${label} runtime action menu`);
|
||||||
const visibleText = await chrome.visibleText();
|
const visibleText = await chrome.visibleText();
|
||||||
@@ -424,6 +458,63 @@ async function verifyServerQuickRuntimeMenu(chrome, label) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyMobileNavigation(chrome, label, themeMarkers) {
|
||||||
|
const menuLabels = ["平台概览", "服务器管理", "插件市场", "用户管理", "AI 提供商", "系统工具"];
|
||||||
|
const closed = await chrome.evaluate(() => {
|
||||||
|
const handle = document.querySelector(".mobile-sidebar-handle");
|
||||||
|
const sidebar = document.querySelector("#primary-sidebar");
|
||||||
|
return {
|
||||||
|
handle: handle instanceof HTMLButtonElement,
|
||||||
|
expanded: handle?.getAttribute("aria-expanded") === "true",
|
||||||
|
sidebarOpen: sidebar?.classList.contains("app-sidebar-mobile-open") ?? false,
|
||||||
|
mainTop: document.querySelector("main")?.getBoundingClientRect().top ?? null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!closed.handle || closed.expanded || closed.sidebarOpen || closed.mainTop !== 0) {
|
||||||
|
throw new Error(`${label} mobile drawer was not closed and out of page flow: ${JSON.stringify(closed)}`);
|
||||||
|
}
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const handle = document.querySelector(".mobile-sidebar-handle");
|
||||||
|
if (!(handle instanceof HTMLButtonElement)) throw new Error("mobile drawer handle not found");
|
||||||
|
handle.click();
|
||||||
|
});
|
||||||
|
await chrome.waitForText([...menuLabels, ...themeMarkers], `${label} mobile drawer open`);
|
||||||
|
const opened = await chrome.evaluate(() => ({
|
||||||
|
expanded: document.querySelector(".mobile-sidebar-handle")?.getAttribute("aria-expanded") === "true",
|
||||||
|
sidebarOpen: document.querySelector("#primary-sidebar")?.classList.contains("app-sidebar-mobile-open") ?? false,
|
||||||
|
width: document.querySelector("#primary-sidebar")?.getBoundingClientRect().width ?? 0
|
||||||
|
}));
|
||||||
|
if (!opened.expanded || !opened.sidebarOpen || opened.width <= 0 || opened.width > 320) {
|
||||||
|
throw new Error(`${label} mobile drawer did not open as a bounded text sidebar: ${JSON.stringify(opened)}`);
|
||||||
|
}
|
||||||
|
const visibleText = await chrome.visibleText();
|
||||||
|
assertMarkers(visibleText, [...menuLabels, ...themeMarkers], `${label} mobile drawer`);
|
||||||
|
scanText(visibleText, `${label} mobile drawer`);
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const backdrop = document.querySelector(".mobile-sidebar-backdrop");
|
||||||
|
if (!(backdrop instanceof HTMLButtonElement)) throw new Error("mobile drawer backdrop not found");
|
||||||
|
backdrop.click();
|
||||||
|
});
|
||||||
|
await delay(220);
|
||||||
|
const closedAgain = await chrome.evaluate(() => {
|
||||||
|
const backdrop = document.querySelector(".mobile-sidebar-backdrop");
|
||||||
|
const style = backdrop ? window.getComputedStyle(backdrop) : null;
|
||||||
|
return document.querySelector(".mobile-sidebar-handle")?.getAttribute("aria-expanded") !== "true"
|
||||||
|
&& style?.visibility === "hidden"
|
||||||
|
&& style?.pointerEvents === "none";
|
||||||
|
});
|
||||||
|
if (!closedAgain) throw new Error(`${label} mobile drawer did not close from backdrop`);
|
||||||
|
return {
|
||||||
|
requiredMarkers: [...menuLabels, ...themeMarkers],
|
||||||
|
fallbackScan: "passed",
|
||||||
|
forbiddenFragmentScan: "passed",
|
||||||
|
closed,
|
||||||
|
opened,
|
||||||
|
closedAgain,
|
||||||
|
textSample: visibleText.slice(0, 1200)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureAiProvider(headers) {
|
async function ensureAiProvider(headers) {
|
||||||
const providers = await getJson("/ai-providers", headers);
|
const providers = await getJson("/ai-providers", headers);
|
||||||
if (providers.items.some((item) => item.id === "ai.openai")) {
|
if (providers.items.some((item) => item.id === "ai.openai")) {
|
||||||
@@ -447,6 +538,218 @@ async function ensureAiProvider(headers) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function prepareProductionOperations(headers, server, plugin) {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const lifecycle = await postJson(
|
||||||
|
`/plugin-lifecycles/${encodeURIComponent(plugin.id)}/actions`,
|
||||||
|
{
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
operation: "install",
|
||||||
|
targetVersion: plugin.version,
|
||||||
|
idempotencyKey: `browser-acceptance-plugin-install-${stamp}`,
|
||||||
|
confirmed: false
|
||||||
|
},
|
||||||
|
headers
|
||||||
|
);
|
||||||
|
if (lifecycle.status !== "queued" || !lifecycle.job?.id || !lifecycle.installation?.id) {
|
||||||
|
throw new Error(`plugin lifecycle seed did not create one durable job: ${JSON.stringify(lifecycle)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiInvocation = await postJson(
|
||||||
|
"/ai/invocations",
|
||||||
|
{
|
||||||
|
requestId: `browser-acceptance-ai-config-${stamp}`,
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
purpose: "config.suggest",
|
||||||
|
prompt: "Keep existing settings and add a reviewed max players recommendation."
|
||||||
|
},
|
||||||
|
headers
|
||||||
|
);
|
||||||
|
if (aiInvocation.status !== "ok" || !aiInvocation.configRecommendation?.diffId) {
|
||||||
|
throw new Error(`AI invocation did not persist a reviewable diff: ${JSON.stringify(aiInvocation)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admission = await postJson(
|
||||||
|
"/production/capacity/admission",
|
||||||
|
{
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
capability: "process.restart",
|
||||||
|
idempotencyKey: `browser-acceptance-capacity-gap-${stamp}`
|
||||||
|
},
|
||||||
|
headers
|
||||||
|
);
|
||||||
|
if (admission.accepted || admission.state !== "denied" || !admission.alertId || !admission.auditEventId) {
|
||||||
|
throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [capacity, alerts, lifecycles, diffs] = await Promise.all([
|
||||||
|
getJson("/production/capacity", headers),
|
||||||
|
getJson("/alerts", headers),
|
||||||
|
getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers),
|
||||||
|
getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(server.id)}`, headers)
|
||||||
|
]);
|
||||||
|
const alert = findRequired(alerts.items, (item) => item.id === admission.alertId && item.state === "active", "active capacity alert");
|
||||||
|
const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation");
|
||||||
|
const diff = findRequired(diffs.items, (item) => item.id === aiInvocation.configRecommendation.diffId && item.state === "pending", "pending AI config diff");
|
||||||
|
if (capacity.activeAlerts < 1 || !capacity.endpoints.some((item) => item.runEndpointId === server.runEndpointId)) {
|
||||||
|
throw new Error(`production capacity summary did not include seeded state: ${JSON.stringify(capacity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [label, value] of Object.entries({ admission, capacity, alert, installation, diff, aiInvocation })) {
|
||||||
|
assertNoForbiddenProjection(value, `production seed ${label}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
alert,
|
||||||
|
diff,
|
||||||
|
apiProof: {
|
||||||
|
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId", "auditEventId"]),
|
||||||
|
capacity: {
|
||||||
|
...pick(capacity, ["totalMaxJobs", "totalRunningJobs", "totalQueuedJobs", "activeAlerts", "generatedAt"]),
|
||||||
|
endpoints: capacity.endpoints.map((item) => pick(item, ["runEndpointId", "status", "maxJobs", "runningJobs", "queuedJobs", "logBacklogBatches", "artifactBacklogChunks", "pressureCodes"]))
|
||||||
|
},
|
||||||
|
alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount", "lastAuditEventId"]),
|
||||||
|
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId", "auditEventId"]),
|
||||||
|
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAlertInteraction(chrome, headers, seededAlert) {
|
||||||
|
await chrome.navigate(`${webUrl}/#/home`);
|
||||||
|
await chrome.waitForText(["生产容量与告警", seededAlert.title, "确认"], "production alert interaction");
|
||||||
|
await chrome.evaluate((title) => {
|
||||||
|
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
|
||||||
|
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
|
||||||
|
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found");
|
||||||
|
button.click();
|
||||||
|
}, seededAlert.title);
|
||||||
|
await chrome.waitForText(["确认告警", seededAlert.id, "取消"], "alert confirmation dialog");
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
|
||||||
|
if (!(cancel instanceof HTMLButtonElement)) throw new Error("alert confirmation cancel button not found");
|
||||||
|
cancel.click();
|
||||||
|
});
|
||||||
|
await delay(100);
|
||||||
|
if (await chrome.evaluate(() => Boolean(document.querySelector(".confirm-panel")))) {
|
||||||
|
throw new Error("alert confirmation dialog did not close after cancel");
|
||||||
|
}
|
||||||
|
const afterCancel = await getJson("/alerts", headers);
|
||||||
|
const stillActive = findRequired(afterCancel.items, (item) => item.id === seededAlert.id, "alert after confirmation cancel");
|
||||||
|
assertEqual(stillActive.state, "active", "cancel keeps durable alert active");
|
||||||
|
|
||||||
|
await chrome.evaluate((title) => {
|
||||||
|
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
|
||||||
|
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
|
||||||
|
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found after cancel");
|
||||||
|
button.click();
|
||||||
|
}, seededAlert.title);
|
||||||
|
await chrome.waitForText(["确认告警", seededAlert.id], "alert confirmation reopen");
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const confirm = document.querySelector(".confirm-panel .confirm-primary");
|
||||||
|
if (!(confirm instanceof HTMLButtonElement)) throw new Error("alert confirmation submit button not found");
|
||||||
|
confirm.click();
|
||||||
|
});
|
||||||
|
await chrome.waitForText(["确认已由 Platform 持久化", "acknowledged"], "durable alert acknowledgement");
|
||||||
|
const alerts = await getJson("/alerts", headers);
|
||||||
|
const acknowledged = findRequired(alerts.items, (item) => item.id === seededAlert.id, "acknowledged capacity alert");
|
||||||
|
assertEqual(acknowledged.state, "acknowledged", "browser alert acknowledgement persisted");
|
||||||
|
assertNoForbiddenProjection(acknowledged, "acknowledged alert response");
|
||||||
|
return {
|
||||||
|
cancelPreservedState: stillActive.state,
|
||||||
|
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt", "lastAuditEventId"]),
|
||||||
|
forbiddenFragmentScan: "passed",
|
||||||
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server) {
|
||||||
|
await chrome.navigate(`${webUrl}/#/plugins`);
|
||||||
|
await chrome.waitForText(["插件市场", plugin.id, "查看详情"], "plugin lifecycle marketplace");
|
||||||
|
await chrome.evaluate((pluginId) => {
|
||||||
|
const card = Array.from(document.querySelectorAll(".catalog-card")).find((candidate) => candidate.textContent?.includes(pluginId));
|
||||||
|
const button = card?.querySelector("button[title='查看插件详情']");
|
||||||
|
if (!(button instanceof HTMLButtonElement)) throw new Error("plugin detail button not found");
|
||||||
|
button.click();
|
||||||
|
}, plugin.id);
|
||||||
|
await chrome.waitForText([plugin.name, "生产生命周期", server.id, "compatible"], "plugin lifecycle detail");
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const select = document.querySelector("select[aria-label='生命周期操作']");
|
||||||
|
if (!(select instanceof HTMLSelectElement)) throw new Error("plugin lifecycle operation select not found");
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
||||||
|
setter.call(select, "enable");
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const button = document.querySelector(".plugin-lifecycle-controls .primary-command");
|
||||||
|
if (!(button instanceof HTMLButtonElement)) throw new Error("plugin lifecycle execute button not found");
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
await chrome.waitForText(["确认启用", plugin.name, server.id], "plugin lifecycle confirmation");
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const confirm = Array.from(document.querySelectorAll(".confirm-panel .confirm-actions button")).find((item) => item.textContent?.trim() === "启用");
|
||||||
|
if (!(confirm instanceof HTMLButtonElement)) throw new Error("plugin lifecycle confirmation submit not found");
|
||||||
|
confirm.click();
|
||||||
|
});
|
||||||
|
await chrome.waitForText(["启用:queued", "任务"], "plugin lifecycle durable dispatch");
|
||||||
|
const response = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||||
|
const installation = findRequired(response.items, (item) => item.pluginId === plugin.id && item.serverInstanceId === server.id, "plugin lifecycle after browser dispatch");
|
||||||
|
assertEqual(installation.lastOperation, "enable", "browser lifecycle operation persisted");
|
||||||
|
if (!installation.jobId || !installation.auditEventId) {
|
||||||
|
throw new Error(`browser lifecycle dispatch missed job/audit linkage: ${JSON.stringify(installation)}`);
|
||||||
|
}
|
||||||
|
assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
|
||||||
|
return {
|
||||||
|
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "auditEventId", "alertId"]),
|
||||||
|
forbiddenFragmentScan: "passed",
|
||||||
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAIConfigDiffInteraction(chrome, headers, seededDiff) {
|
||||||
|
await chrome.navigate(`${webUrl}/#/aiProviders`);
|
||||||
|
await chrome.waitForText(["AI 配置审查", seededDiff.serverInstanceId, seededDiff.diffSummary, "审查并批准"], "AI config diff review");
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
||||||
|
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found");
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id, "取消"], "AI diff approval confirmation");
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
|
||||||
|
if (!(cancel instanceof HTMLButtonElement)) throw new Error("AI diff approval cancel button not found");
|
||||||
|
cancel.click();
|
||||||
|
});
|
||||||
|
const pendingResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
||||||
|
const pending = findRequired(pendingResponse.items, (item) => item.id === seededDiff.id, "AI diff after approval cancel");
|
||||||
|
assertEqual(pending.state, "pending", "cancel keeps AI diff pending");
|
||||||
|
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
||||||
|
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found after cancel");
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id], "AI diff approval confirmation reopen");
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const confirm = document.querySelector(".confirm-panel .confirm-primary");
|
||||||
|
if (!(confirm instanceof HTMLButtonElement)) throw new Error("AI diff approval submit button not found");
|
||||||
|
confirm.click();
|
||||||
|
});
|
||||||
|
await chrome.waitForText(["已审批", "写入任务"], "AI diff durable approval");
|
||||||
|
const approvedResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
||||||
|
const approved = findRequired(approvedResponse.items, (item) => item.id === seededDiff.id, "approved AI config diff");
|
||||||
|
assertEqual(approved.state, "approved", "browser AI diff approval persisted");
|
||||||
|
if (!approved.jobId || !approved.approvedBy || !approved.approvedAt) {
|
||||||
|
throw new Error(`approved AI diff missed durable approval linkage: ${JSON.stringify(approved)}`);
|
||||||
|
}
|
||||||
|
assertNoForbiddenProjection(approved, "approved AI diff response");
|
||||||
|
return {
|
||||||
|
cancelPreservedState: pending.state,
|
||||||
|
persisted: pick(approved, ["id", "serverInstanceId", "state", "approvedBy", "approvedAt", "jobId", "configVersion", "currentConfigChecksum"]),
|
||||||
|
forbiddenFragmentScan: "passed",
|
||||||
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function verifyFrontendEnvironment() {
|
async function verifyFrontendEnvironment() {
|
||||||
const [html, packageJsonText, viteConfigText] = await Promise.all([
|
const [html, packageJsonText, viteConfigText] = await Promise.all([
|
||||||
fetchText(webUrl),
|
fetchText(webUrl),
|
||||||
@@ -470,34 +773,42 @@ async function verifyFrontendEnvironment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function verifyLifecycleOperation(headers, server, chrome) {
|
async function verifyLifecycleOperation(headers, server, chrome) {
|
||||||
const request = { expectedConfigVersion: server.configVersion, idempotencyKey: `browser-acceptance-start-${Date.now()}` };
|
const currentServer = await getJson(`/server-instances/${encodeURIComponent(server.id)}`, headers);
|
||||||
const result = await postJson(`/server-instances/${encodeURIComponent(server.id)}/start`, request, headers);
|
if (currentServer.configVersion <= server.configVersion) {
|
||||||
|
throw new Error(`AI config write did not advance the durable server config version: before=${server.configVersion} after=${currentServer.configVersion}`);
|
||||||
|
}
|
||||||
|
const action = currentServer.state === "running" ? "stop" : "start";
|
||||||
|
const expectedCapability = action === "stop" ? "process.stop" : "process.start";
|
||||||
|
const request = { expectedConfigVersion: currentServer.configVersion, idempotencyKey: `browser-acceptance-${action}-${Date.now()}` };
|
||||||
|
const result = await postJson(`/server-instances/${encodeURIComponent(currentServer.id)}/${action}`, request, headers);
|
||||||
if (!result.accepted || !result.job?.id) {
|
if (!result.accepted || !result.job?.id) {
|
||||||
throw new Error("lifecycle start operation did not return accepted job evidence");
|
throw new Error("lifecycle start operation did not return accepted job evidence");
|
||||||
}
|
}
|
||||||
if (result.job.capability !== "process.start") {
|
if (result.job.capability !== expectedCapability) {
|
||||||
throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`);
|
throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`);
|
||||||
}
|
}
|
||||||
if (result.job.runEndpointId !== "run-local-debug") {
|
if (result.job.runEndpointId !== "run-local-debug") {
|
||||||
throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`);
|
throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await waitForJob(headers, server.id, result.job.id);
|
const job = await waitForJob(headers, currentServer.id, result.job.id);
|
||||||
|
|
||||||
await chrome.navigate(`${webUrl}/#/servers/server-local-debug`);
|
await chrome.navigate(`${webUrl}/#/servers/server-local-debug`);
|
||||||
await chrome.waitForText([server.name, "操作历史"], "server detail after lifecycle operation");
|
await chrome.waitForText([currentServer.name, "操作历史"], "server detail after lifecycle operation");
|
||||||
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
|
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: "start",
|
action,
|
||||||
accepted: result.accepted,
|
accepted: result.accepted,
|
||||||
request: {
|
request: {
|
||||||
expectedConfigVersion: request.expectedConfigVersion,
|
expectedConfigVersion: request.expectedConfigVersion,
|
||||||
idempotencyKey: request.idempotencyKey
|
idempotencyKey: request.idempotencyKey,
|
||||||
|
configVersionBeforeAIWrite: server.configVersion,
|
||||||
|
configVersionAfterAIWrite: currentServer.configVersion
|
||||||
},
|
},
|
||||||
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
||||||
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
||||||
proof: "platform API accepted process.start 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 operation history entry point without direct run access`,
|
||||||
browserEvidence: {
|
browserEvidence: {
|
||||||
...historyState,
|
...historyState,
|
||||||
proofMode: "operation-history-entry-point"
|
proofMode: "operation-history-entry-point"
|
||||||
@@ -894,6 +1205,18 @@ function scanText(text, routeName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertNoForbiddenProjection(value, label) {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
const forbidden = forbiddenFragments.find((item) => item.pattern.test(serialized));
|
||||||
|
if (forbidden) {
|
||||||
|
throw new Error(`${label} contains forbidden API fragment: ${forbidden.name}`);
|
||||||
|
}
|
||||||
|
const forbiddenField = /"(?:apiKey|apiKeyRef|rawApiKey|baseUrl|hostPath|pid|socket|dsn|rcon|credential|directRunEndpoint)"\s*:/i.exec(serialized);
|
||||||
|
if (forbiddenField) {
|
||||||
|
throw new Error(`${label} contains forbidden API field ${forbiddenField[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertEqual(actual, expected, label) {
|
function assertEqual(actual, expected, label) {
|
||||||
if (actual !== expected) {
|
if (actual !== expected) {
|
||||||
throw new Error(`${label}: expected ${expected}, got ${actual}`);
|
throw new Error(`${label}: expected ${expected}, got ${actual}`);
|
||||||
@@ -906,12 +1229,6 @@ function assertIncludes(values, expected, label) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSafeRedactedRef(value, label) {
|
|
||||||
if (typeof value !== "string" || (!value.startsWith("secret://") && !value.startsWith("env://"))) {
|
|
||||||
throw new Error(`${label} must be a safe secret/env reference`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(value, keys) {
|
function pick(value, keys) {
|
||||||
return Object.fromEntries(keys.map((key) => [key, value?.[key]]));
|
return Object.fromEntries(keys.map((key) => [key, value?.[key]]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const provider: AiProviderResponse = {
|
|||||||
id: "ai.openai",
|
id: "ai.openai",
|
||||||
name: "OpenAI",
|
name: "OpenAI",
|
||||||
kind: "openai",
|
kind: "openai",
|
||||||
baseUrl: "https://api.openai.com/v1",
|
baseUrlConfigured: true,
|
||||||
apiKeyConfigured: true,
|
apiKeyConfigured: true,
|
||||||
models: ["gpt-4.1"],
|
models: ["gpt-4.1"],
|
||||||
defaultModel: "gpt-4.1",
|
defaultModel: "gpt-4.1",
|
||||||
@@ -35,6 +35,7 @@ const plugin: GamePluginResponse = {
|
|||||||
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
|
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
|
||||||
tags: ["example"],
|
tags: ["example"],
|
||||||
aiPurposes: ["logs.diagnose"],
|
aiPurposes: ["logs.diagnose"],
|
||||||
|
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
|
||||||
runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] },
|
runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] },
|
||||||
status: "installed"
|
status: "installed"
|
||||||
};
|
};
|
||||||
@@ -57,6 +58,7 @@ const marketplacePlugin: MarketplacePluginResponse = {
|
|||||||
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
|
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
|
||||||
tags: ["example"],
|
tags: ["example"],
|
||||||
aiPurposes: ["logs.diagnose"],
|
aiPurposes: ["logs.diagnose"],
|
||||||
|
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
|
||||||
status: "installed",
|
status: "installed",
|
||||||
source: "platform-registry"
|
source: "platform-registry"
|
||||||
};
|
};
|
||||||
@@ -181,7 +183,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
id: provider.id,
|
id: provider.id,
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
kind: provider.kind,
|
kind: provider.kind,
|
||||||
baseUrl: provider.baseUrl,
|
baseUrl: "https://api.openai.com/v1",
|
||||||
apiKeyRef: "secret://providers/openai",
|
apiKeyRef: "secret://providers/openai",
|
||||||
models: provider.models,
|
models: provider.models,
|
||||||
defaultModel: provider.defaultModel,
|
defaultModel: provider.defaultModel,
|
||||||
@@ -725,11 +727,13 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps raw key fields out of provider responses", () => {
|
it("keeps raw key and base URL fields out of provider responses", () => {
|
||||||
expect("apiKey" in provider).toBe(false);
|
expect("apiKey" in provider).toBe(false);
|
||||||
expect("rawApiKey" in provider).toBe(false);
|
expect("rawApiKey" in provider).toBe(false);
|
||||||
expect(provider.apiKeyConfigured).toBe(true);
|
expect(provider.apiKeyConfigured).toBe(true);
|
||||||
expect("apiKeyRef" in provider).toBe(false);
|
expect("apiKeyRef" in provider).toBe(false);
|
||||||
|
expect("baseUrl" in provider).toBe(false);
|
||||||
|
expect(provider.baseUrlConfigured).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls auth endpoints and attaches bearer sessions", async () => {
|
it("calls auth endpoints and attaches bearer sessions", async () => {
|
||||||
@@ -804,6 +808,24 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
});
|
});
|
||||||
expect(onAuthFailure).not.toHaveBeenCalled();
|
expect(onAuthFailure).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces allow-listed plugin capability denials", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||||
|
code: "forbidden",
|
||||||
|
message: "plugin does not declare required permission: server.run.distribution"
|
||||||
|
}), { status: 403, headers: { "Content-Type": "application/json" } })));
|
||||||
|
|
||||||
|
const client = new PlatformApiClient("/api/v1", () => "admin-session");
|
||||||
|
await expect(client.generateRunDistribution("server-local-debug", {
|
||||||
|
targetOs: "linux",
|
||||||
|
targetArch: "amd64",
|
||||||
|
idempotencyKey: "test"
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
code: "forbidden",
|
||||||
|
message: "插件未声明所需权限:server.run.distribution"
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(body: unknown): Response {
|
function jsonResponse(body: unknown): Response {
|
||||||
|
|||||||
+169
-1
@@ -8,6 +8,11 @@ import type {
|
|||||||
AiProviderUpdateRequest,
|
AiProviderUpdateRequest,
|
||||||
AIInvocationRequest,
|
AIInvocationRequest,
|
||||||
AIInvocationResponse,
|
AIInvocationResponse,
|
||||||
|
AIConfigDiffApprovalResponse,
|
||||||
|
AIConfigDiffListResponse,
|
||||||
|
AlertListResponse,
|
||||||
|
AlertResponse,
|
||||||
|
AlertRetryResponse,
|
||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ArtifactContentChunk,
|
ArtifactContentChunk,
|
||||||
ArtifactDownloadReferenceResponse,
|
ArtifactDownloadReferenceResponse,
|
||||||
@@ -16,8 +21,16 @@ import type {
|
|||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuditEventListResponse,
|
AuditEventListResponse,
|
||||||
ClientManagerBuildRequest,
|
ClientManagerBuildRequest,
|
||||||
|
ClientManagerControlRequest,
|
||||||
|
ClientManagerDeployRequest,
|
||||||
ClientManagerDistributionResponse,
|
ClientManagerDistributionResponse,
|
||||||
ClientManagerDownloadRequest,
|
ClientManagerDownloadRequest,
|
||||||
|
ClientManagerInstallationListResponse,
|
||||||
|
ClientManagerInstallationResponse,
|
||||||
|
ClientManagerRetryRequest,
|
||||||
|
ClientManagerRevokeSessionRequest,
|
||||||
|
ClientManagerUninstallRequest,
|
||||||
|
ClientManagerUpdateRequest,
|
||||||
ComponentKeyResponse,
|
ComponentKeyResponse,
|
||||||
ComponentKeyResetRequest,
|
ComponentKeyResetRequest,
|
||||||
CurrentUserResponse,
|
CurrentUserResponse,
|
||||||
@@ -25,6 +38,15 @@ import type {
|
|||||||
DependencyJobRequest,
|
DependencyJobRequest,
|
||||||
FileOperationDispatchRequest,
|
FileOperationDispatchRequest,
|
||||||
FileOperationDispatchResponse,
|
FileOperationDispatchResponse,
|
||||||
|
GameClientBridgeCancelRequest,
|
||||||
|
GameClientBridgeCancelResponse,
|
||||||
|
GameClientBridgeCommandFilterRequest,
|
||||||
|
GameClientBridgeCommandListResponse,
|
||||||
|
GameClientBridgeCommandResponse,
|
||||||
|
GameClientBridgeQueueRequest,
|
||||||
|
GameClientBridgeSnapshotListResponse,
|
||||||
|
GameClientBridgeSnapshotQuery,
|
||||||
|
GameClientBridgeStatusResponse,
|
||||||
GamePluginListResponse,
|
GamePluginListResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
JobCreateRequest,
|
JobCreateRequest,
|
||||||
@@ -42,6 +64,11 @@ import type {
|
|||||||
MarketplacePluginResponse,
|
MarketplacePluginResponse,
|
||||||
MarketplacePluginStateRequest,
|
MarketplacePluginStateRequest,
|
||||||
PlatformResourceUsageResponse,
|
PlatformResourceUsageResponse,
|
||||||
|
ProductionCapacitySummaryResponse,
|
||||||
|
CapacityAdmissionDecisionResponse,
|
||||||
|
PluginLifecycleActionRequest,
|
||||||
|
PluginLifecycleActionResponse,
|
||||||
|
PluginLifecycleListResponse,
|
||||||
PluginBridgeAuthorizeRequest,
|
PluginBridgeAuthorizeRequest,
|
||||||
PluginBridgeAuthorizeResponse,
|
PluginBridgeAuthorizeResponse,
|
||||||
PluginBridgeExecuteRequest,
|
PluginBridgeExecuteRequest,
|
||||||
@@ -86,6 +113,15 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import { readWebRuntimeEnv } from "../schemas/env";
|
import { readWebRuntimeEnv } from "../schemas/env";
|
||||||
import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates";
|
import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates";
|
||||||
|
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "../schemas/clientManagerLifecycle";
|
||||||
|
import {
|
||||||
|
parseSafeGameClientBridgeCancellation,
|
||||||
|
parseSafeGameClientBridgeCommand,
|
||||||
|
parseSafeGameClientBridgeCommandList,
|
||||||
|
parseSafeGameClientBridgeSnapshotList,
|
||||||
|
parseSafeGameClientBridgeStatus
|
||||||
|
} from "../schemas/gameClientBridge";
|
||||||
|
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
|
||||||
|
|
||||||
let platformApiSessionToken: string | null = null;
|
let platformApiSessionToken: string | null = null;
|
||||||
let platformApiAuthFailureHandler: ((error: PlatformApiError) => void) | null = null;
|
let platformApiAuthFailureHandler: ((error: PlatformApiError) => void) | null = null;
|
||||||
@@ -298,6 +334,74 @@ export class PlatformApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listClientManagerLifecycles(id: string): Promise<ClientManagerInstallationListResponse> {
|
||||||
|
return parseSafeClientManagerLifecycleList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClientManagerLifecycle(id: string, profileKey: string): Promise<ClientManagerInstallationResponse> {
|
||||||
|
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/${encodeURIComponent(profileKey)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployClientManager(id: string, request: ClientManagerDeployRequest): Promise<ClientManagerInstallationResponse> {
|
||||||
|
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/deploy`, { method: "POST", body: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async controlClientManager(id: string, request: ClientManagerControlRequest): Promise<ClientManagerInstallationResponse> {
|
||||||
|
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/control`, { method: "POST", body: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateClientManager(id: string, request: ClientManagerUpdateRequest): Promise<ClientManagerInstallationResponse> {
|
||||||
|
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/update`, { method: "POST", body: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryClientManagerLifecycle(id: string, request: ClientManagerRetryRequest): Promise<ClientManagerInstallationResponse> {
|
||||||
|
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/retry`, { method: "POST", body: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeClientManagerSession(id: string, request: ClientManagerRevokeSessionRequest): Promise<ClientManagerInstallationResponse> {
|
||||||
|
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/revoke-session`, { method: "POST", body: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallClientManager(id: string, request: ClientManagerUninstallRequest): Promise<ClientManagerInstallationResponse> {
|
||||||
|
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/uninstall`, { method: "POST", body: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGameClientBridgeStatus(id: string): Promise<GameClientBridgeStatusResponse> {
|
||||||
|
return parseSafeGameClientBridgeStatus(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGameClientBridgeCommands(id: string, filter: GameClientBridgeCommandFilterRequest = {}): Promise<GameClientBridgeCommandListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter.profileKey) params.set("profileKey", filter.profileKey);
|
||||||
|
if (filter.state) params.set("state", filter.state);
|
||||||
|
if (filter.commandType) params.set("commandType", filter.commandType);
|
||||||
|
const query = params.toString();
|
||||||
|
return parseSafeGameClientBridgeCommandList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands${query ? `?${query}` : ""}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueGameClientBridgeCommand(id: string, request: GameClientBridgeQueueRequest): Promise<GameClientBridgeCommandResponse> {
|
||||||
|
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands`, { method: "POST", body: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGameClientBridgeCommand(id: string, commandId: string): Promise<GameClientBridgeCommandResponse> {
|
||||||
|
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelGameClientBridgeCommand(id: string, commandId: string, request: GameClientBridgeCancelRequest = {}): Promise<GameClientBridgeCancelResponse> {
|
||||||
|
return parseSafeGameClientBridgeCancellation(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}/cancel`, { method: "POST", body: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGameClientBridgeSnapshots(id: string, query: GameClientBridgeSnapshotQuery = {}): Promise<GameClientBridgeSnapshotListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.profileKey) params.set("profileKey", query.profileKey);
|
||||||
|
if (query.type) params.set("type", query.type);
|
||||||
|
if (query.streamKey) params.set("streamKey", query.streamKey);
|
||||||
|
if (query.observedAfter) params.set("observedAfter", query.observedAfter);
|
||||||
|
if (query.limit !== undefined) params.set("limit", String(query.limit));
|
||||||
|
const search = params.toString();
|
||||||
|
return parseSafeGameClientBridgeSnapshotList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/snapshots${search ? `?${search}` : ""}`));
|
||||||
|
}
|
||||||
|
|
||||||
async checkDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
|
async checkDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
|
||||||
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/check`, {
|
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/check`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -386,6 +490,55 @@ export class PlatformApiClient {
|
|||||||
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
|
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProductionCapacity(): Promise<ProductionCapacitySummaryResponse> {
|
||||||
|
return this.request<ProductionCapacitySummaryResponse>("/production/capacity");
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkCapacityAdmission(request: { serverInstanceId?: string; runEndpointId?: string; capability: string; targetKey?: string; idempotencyKey?: string }): Promise<CapacityAdmissionDecisionResponse> {
|
||||||
|
return this.request<CapacityAdmissionDecisionResponse>("/production/capacity/admission", { method: "POST", body: request });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAlerts(filter: { state?: string; sourceKind?: string; sourceId?: string; severity?: string } = {}): Promise<AlertListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
|
||||||
|
const query = params.toString();
|
||||||
|
return this.request<AlertListResponse>(`/alerts${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acknowledgeAlert(id: string, note = ""): Promise<AlertResponse> {
|
||||||
|
return this.request<AlertResponse>(`/alerts/${encodeURIComponent(id)}/acknowledge`, { method: "POST", body: { note } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveAlert(id: string, note = ""): Promise<AlertResponse> {
|
||||||
|
return this.request<AlertResponse>(`/alerts/${encodeURIComponent(id)}/resolve`, { method: "POST", body: { note } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryAlert(id: string, idempotencyKey: string): Promise<AlertRetryResponse> {
|
||||||
|
return this.request<AlertRetryResponse>(`/alerts/${encodeURIComponent(id)}/retry`, { method: "POST", body: { idempotencyKey } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPluginLifecycles(filter: { pluginId?: string; serverInstanceId?: string; currentState?: string } = {}): Promise<PluginLifecycleListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
|
||||||
|
const query = params.toString();
|
||||||
|
return this.request<PluginLifecycleListResponse>(`/plugin-lifecycles${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runPluginLifecycle(pluginId: string, request: PluginLifecycleActionRequest): Promise<PluginLifecycleActionResponse> {
|
||||||
|
return this.request<PluginLifecycleActionResponse>(`/plugin-lifecycles/${encodeURIComponent(pluginId)}/actions`, { method: "POST", body: request });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAIConfigDiffs(filter: { serverInstanceId?: string; pluginId?: string; state?: string } = {}): Promise<AIConfigDiffListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
|
||||||
|
const query = params.toString();
|
||||||
|
return this.request<AIConfigDiffListResponse>(`/ai/config-diffs${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveAIConfigDiff(id: string, idempotencyKey: string): Promise<AIConfigDiffApprovalResponse> {
|
||||||
|
return this.request<AIConfigDiffApprovalResponse>(`/ai/config-diffs/${encodeURIComponent(id)}/approve`, { method: "POST", body: { idempotencyKey } });
|
||||||
|
}
|
||||||
|
|
||||||
async listServerMetrics(): Promise<ServerMetricsListResponse> {
|
async listServerMetrics(): Promise<ServerMetricsListResponse> {
|
||||||
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
|
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
|
||||||
}
|
}
|
||||||
@@ -556,7 +709,7 @@ async function responseError(response: Response): Promise<PlatformApiError> {
|
|||||||
const message = response.status === 401
|
const message = response.status === 401
|
||||||
? "会话已失效,请重新登录。"
|
? "会话已失效,请重新登录。"
|
||||||
: response.status === 403
|
: response.status === 403
|
||||||
? "没有权限访问该资源。"
|
? safeForbiddenMessage(apiError?.message)
|
||||||
: apiError?.message ?? `request failed: ${response.status}`;
|
: apiError?.message ?? `request failed: ${response.status}`;
|
||||||
const error = new PlatformApiError(response.status, apiError?.code ?? "request_failed", message);
|
const error = new PlatformApiError(response.status, apiError?.code ?? "request_failed", message);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -566,6 +719,21 @@ async function responseError(response: Response): Promise<PlatformApiError> {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeForbiddenMessage(apiMessage?: string): string {
|
||||||
|
const sanitized = safeDiagnosticText(apiMessage, "")?.trim();
|
||||||
|
if (!sanitized || sanitized === "account is not allowed to access this resource") {
|
||||||
|
return "没有权限访问该资源。";
|
||||||
|
}
|
||||||
|
const missingPermission = sanitized.match(/^plugin does not declare required permission:\s*([a-z0-9._-]+)$/i);
|
||||||
|
if (missingPermission) {
|
||||||
|
return `插件未声明所需权限:${missingPermission[1]}`;
|
||||||
|
}
|
||||||
|
if (sanitized === "plugin is not installed") {
|
||||||
|
return "插件未安装,不能执行该操作。";
|
||||||
|
}
|
||||||
|
return "没有权限访问该资源。";
|
||||||
|
}
|
||||||
|
|
||||||
function marketplaceQuery(filter: MarketplacePluginFilterRequest): string {
|
function marketplaceQuery(filter: MarketplacePluginFilterRequest): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filter.status && filter.status !== "all") {
|
if (filter.status && filter.status !== "all") {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { PlatformApiClient } from "./client";
|
||||||
|
|
||||||
|
const lifecycle = {
|
||||||
|
id: "installation-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client-manager", targetOs: "windows", targetArch: "amd64",
|
||||||
|
status: "available", phase: "artifact available", desiredVersion: "1.0.0", desiredRevision: "rev-1", desiredArtifactId: "artifact-1", keyGeneration: 1,
|
||||||
|
deploymentGeneration: 0, health: "unknown", healthReason: "component is not installed", retryable: false, requiresRedeploy: false, updatedAt: "2026-07-18T08:00:00Z",
|
||||||
|
distribution: { id: "distribution-1", artifactId: "artifact-1", sourceRevision: "rev-1", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 1, status: "available" },
|
||||||
|
actions: [{ operation: "deploy", available: true }, { operation: "uninstall", available: false, reason: "not installed" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("PlatformApiClient Client Manager lifecycle", () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("uses typed Platform lifecycle routes and preserves action bodies", async () => {
|
||||||
|
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
calls.push({ url, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
||||||
|
return new Response(JSON.stringify(url.endsWith("/client-managers") && (init?.method ?? "GET") === "GET" ? { items: [lifecycle], count: 1 } : lifecycle), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}));
|
||||||
|
const client = new PlatformApiClient("/api/v1", () => "session-token");
|
||||||
|
|
||||||
|
await expect(client.listClientManagerLifecycles("server-1")).resolves.toMatchObject({ count: 1 });
|
||||||
|
await expect(client.getClientManagerLifecycle("server-1", "scum-client-manager")).resolves.toMatchObject({ profileKey: "scum-client-manager" });
|
||||||
|
await client.deployClientManager("server-1", { profileKey: "scum-client-manager", distributionId: "distribution-1", expectedDeploymentGeneration: 0, idempotencyKey: "deploy-1" });
|
||||||
|
await client.controlClientManager("server-1", { profileKey: "scum-client-manager", operation: "start", expectedDeploymentGeneration: 1, idempotencyKey: "start-1" });
|
||||||
|
await client.updateClientManager("server-1", { profileKey: "scum-client-manager", distributionId: "distribution-2", expectedDeploymentGeneration: 1, approved: true, idempotencyKey: "update-1" });
|
||||||
|
await client.retryClientManagerLifecycle("server-1", { profileKey: "scum-client-manager", expectedDeploymentGeneration: 2, idempotencyKey: "retry-1" });
|
||||||
|
await client.revokeClientManagerSession("server-1", { profileKey: "scum-client-manager", reason: "operator revoked component session" });
|
||||||
|
await client.uninstallClientManager("server-1", { profileKey: "scum-client-manager", expectedDeploymentGeneration: 2, confirmed: true, idempotencyKey: "uninstall-1" });
|
||||||
|
|
||||||
|
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||||
|
"GET /api/v1/server-instances/server-1/client-managers",
|
||||||
|
"GET /api/v1/server-instances/server-1/client-managers/scum-client-manager",
|
||||||
|
"POST /api/v1/server-instances/server-1/client-managers/deploy",
|
||||||
|
"POST /api/v1/server-instances/server-1/client-managers/control",
|
||||||
|
"POST /api/v1/server-instances/server-1/client-managers/update",
|
||||||
|
"POST /api/v1/server-instances/server-1/client-managers/retry",
|
||||||
|
"POST /api/v1/server-instances/server-1/client-managers/revoke-session",
|
||||||
|
"POST /api/v1/server-instances/server-1/client-managers/uninstall"
|
||||||
|
]);
|
||||||
|
expect(calls[4]?.body).toMatchObject({ approved: true, distributionId: "distribution-2" });
|
||||||
|
expect(calls[7]?.body).toMatchObject({ confirmed: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user