Complete platform management workflows
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.platform-data
|
||||||
|
.run-workspace
|
||||||
|
.tmp
|
||||||
|
node_modules
|
||||||
|
platform_web/node_modules
|
||||||
|
platform_web/dist
|
||||||
|
plugins/node_modules
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Root local development defaults.
|
||||||
|
# Copy to .env if you want docker compose to read custom values automatically.
|
||||||
|
|
||||||
|
PLATFORM_ADDR=:8080
|
||||||
|
|
||||||
|
# Platform metadata storage backend:
|
||||||
|
# - file: default, writes platform metadata JSON to PLATFORM_METADATA_PATH.
|
||||||
|
# - mysql: writes platform metadata to MySQL using PLATFORM_MYSQL_DSN.
|
||||||
|
# - memory: tests/disposable local runs only.
|
||||||
|
PLATFORM_STORAGE_BACKEND=file
|
||||||
|
|
||||||
|
# MySQL metadata example. Change PLATFORM_STORAGE_BACKEND above from file to mysql,
|
||||||
|
# then uncomment and adjust PLATFORM_MYSQL_DSN.
|
||||||
|
# MySQL is for users/plugins/servers/jobs/audit/log stream metadata, not row-per-log-line bodies.
|
||||||
|
# PLATFORM_MYSQL_DSN=platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true
|
||||||
|
|
||||||
|
PLATFORM_DATA_DIR=.platform-data
|
||||||
|
PLATFORM_METADATA_PATH=.platform-data/metadata.json
|
||||||
|
|
||||||
|
# Log body storage is separate from metadata storage.
|
||||||
|
# Keep this as file unless you are explicitly running disposable tests with memory.
|
||||||
|
# For high-volume production logs, add a future LogBodyStore adapter such as ClickHouse/Loki/OpenSearch.
|
||||||
|
PLATFORM_LOG_BODY_BACKEND=file
|
||||||
|
PLATFORM_LOG_DIR=.platform-data/logs
|
||||||
|
|
||||||
|
RUN_MODE=worker
|
||||||
|
RUN_PLATFORM_URL=http://127.0.0.1:8080
|
||||||
|
RUN_ENDPOINT_ID=run-local
|
||||||
|
RUN_DISPLAY_NAME=Local Run
|
||||||
|
RUN_VERSION=0.1.0
|
||||||
|
RUN_REGISTRATION_TOKEN=local-registration
|
||||||
|
RUN_WORKSPACE_ROOT=.run-workspace
|
||||||
|
RUN_SPOOL_ROOT=.run-workspace/spool
|
||||||
|
RUN_MAX_JOBS=1
|
||||||
|
RUN_HEARTBEAT_INTERVAL_MS=15000
|
||||||
|
RUN_POLL_INTERVAL_MS=2000
|
||||||
|
RUN_RETRY_BACKOFF_MS=1000
|
||||||
|
|
||||||
|
VITE_PLATFORM_API_BASE_URL=/api/v1
|
||||||
|
PLATFORM_API_PROXY=http://127.0.0.1:8080
|
||||||
|
VITE_ENABLE_LOCAL_AUTH_FALLBACK=true
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
.tmp
|
||||||
|
.local-debug
|
||||||
|
.idea
|
||||||
|
.claude
|
||||||
|
.github
|
||||||
|
.codex
|
||||||
|
.agents
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-13
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The platform already has separate run control, job, log, and artifact channels. Plugin manifests currently declare lifecycle, file, log, artifact, and AI capabilities, but they do not describe whether a server may be reached by FTP, rsync, or run, nor do they declare run-only operations such as remote database reads or RCON. The previous SCUM codebase used FTP/SFTP workers for file upload, scum_run for server-side operations, SQLite query forwarding, log transfer, and game-client command handling; this change brings those ideas into the new platform as typed, plugin-declared capabilities.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Model remote access methods and remote run operations in the plugin manifest.
|
||||||
|
- Require installed plugins to declare remote capabilities before platform bridge or direct job dispatch can queue them.
|
||||||
|
- Keep remote file/database/RCON/log operations behind platform and run channels.
|
||||||
|
- Allow both SCUM and Minecraft plugins to declare compatible remote access needs.
|
||||||
|
- Keep job payloads bounded: logical target keys, scoped input/artifact refs, and safe result refs only.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- No raw FTP, rsync, database, or RCON credentials in plugin manifests or plugin pages.
|
||||||
|
- No browser-to-run, plugin-to-run, or plugin-to-database direct connection.
|
||||||
|
- No production FTP/rsync client, MySQL client, SQLite parser, or RCON protocol implementation in this change.
|
||||||
|
- No cloud host provider workflow or server rental/billing feature.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: Manifest declares methods and capabilities separately
|
||||||
|
|
||||||
|
`remoteAccess.methods` describes how a server may be reached (`ftp`, `rsync`, `run`). `remoteAccess.runCapabilities` lists the exact run operations the plugin can use. This keeps transport choice visible while still preserving the existing run capability matching model.
|
||||||
|
|
||||||
|
Alternative considered: encode everything as free-form tags. Rejected because marketplace and authorization need deterministic validation and filtering.
|
||||||
|
|
||||||
|
### Decision 2: Remote access uses a dedicated bridge action
|
||||||
|
|
||||||
|
Plugin pages use `remote.access.request` for database, RCON, log transfer, and remote file jobs. Existing `files.request` remains for generic scoped file operations; lifecycle `jobs.dispatch` remains for start/stop. The new action lets platform apply a distinct `server.remote.access` permission and capability declaration check.
|
||||||
|
|
||||||
|
Alternative considered: overload `jobs.dispatch` for every remote operation. Rejected because RCON/database/log transfer should not be authorized only by `server.lifecycle`.
|
||||||
|
|
||||||
|
### Decision 3: Direct server-bound jobs are plugin-gated
|
||||||
|
|
||||||
|
When a job includes a `serverInstanceId`, platform validates the instance plugin declares the requested capability. This prevents callers from bypassing plugin bridge and enabling remote DB/RCON/log transfer jobs on plugins that did not opt in.
|
||||||
|
|
||||||
|
Alternative considered: enforce declaration only in plugin bridge. Rejected because platform API callers can create jobs directly in tests and future admin flows.
|
||||||
|
|
||||||
|
### Decision 4: Run remote executor is bounded metadata first
|
||||||
|
|
||||||
|
Run reports remote capabilities and accepts remote jobs, but returns safe metadata/result refs rather than opening real network connections in this change. Real FTP/rsync/database/RCON adapters can later attach behind the same job envelopes without changing plugin manifests.
|
||||||
|
|
||||||
|
Alternative considered: implement protocol clients now. Rejected because credential storage, network policy, and result artifact formats need a separate change and should not be rushed into manifest plumbing.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] Operators may expect real FTP/rsync/DB/RCON execution immediately. Mitigation: docs and result messages state this is the declared, bounded job contract; adapter implementation remains a follow-up.
|
||||||
|
- [Risk] Capability names may grow numerous. Mitigation: keep them grouped under `remote.*` and validate them centrally.
|
||||||
|
- [Risk] Existing tests using generic jobs may fail once server-bound jobs are plugin-gated. Mitigation: lifecycle/file capabilities remain declared in existing test plugin fixtures.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Add OpenSpec, schema, domain, DTO, validator, and service support for remote access declarations.
|
||||||
|
2. Update SCUM and add Minecraft example manifests with declared remote access.
|
||||||
|
3. Extend run protocol and runtime to validate/report remote capabilities and complete bounded remote jobs.
|
||||||
|
4. Add tests for manifest validation, platform authorization, direct job gating, and run remote job handling.
|
||||||
|
5. Run manifest validation, platform/run tests, structure check, and strict OpenSpec validation.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which follow-up change should own real FTP/rsync credential storage and connection testing?
|
||||||
|
- Should RCON results become log entries, artifacts, or both when full adapters land?
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The platform needs one safe model for remote game server access across hosted files, run-managed hosts, databases, logs, and RCON. The old SCUM stack handled FTP/SFTP, run-side process control, SQLite queries, logs, and client commands in separate paths; the new platform must make those abilities plugin-declared before they can be used.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add plugin manifest metadata for declared remote access methods: `ftp`, `rsync`, and `run`.
|
||||||
|
- Add remote run capabilities for remote file read/write, process start/stop, MySQL and SQLite query, log transfer, and RCON command dispatch.
|
||||||
|
- Add platform registry and marketplace projection fields so operators can see which remote access methods a game plugin enables.
|
||||||
|
- Add a platform-mediated bridge action for remote access requests that queues only capabilities declared by the installed plugin.
|
||||||
|
- Extend run capability reporting and bounded remote job handling so MC and SCUM plugins can opt into the same contract.
|
||||||
|
- Add first-party Minecraft and SCUM example manifests proving the shared declaration model.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `plugin-declared-remote-access`: Plugin-declared remote server access through ftp, rsync, or run with scoped run jobs for remote files, lifecycle, database reads, log transfer, and RCON.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects `plugins/` manifest schema, manifest validator tests, and first-party example manifests.
|
||||||
|
- Affects `platform/` domain, DTO, validators, service bridge dispatch, marketplace projections, docs, and tests.
|
||||||
|
- Affects `run/` protocol capability constants, job validation, runtime capability reporting, worker execution, docs, and tests.
|
||||||
|
- Does not add billing, cloud host sales, plugin-owned credentials, browser-to-run access, raw host paths, or raw database/RCON credentials in plugin pages.
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Plugin manifests declare remote access methods
|
||||||
|
Game management plugin manifests SHALL declare remote access methods before platform exposes FTP, rsync, or run-mediated remote server operations for server instances created from that plugin.
|
||||||
|
|
||||||
|
#### Scenario: Manifest declares supported methods
|
||||||
|
- **WHEN** a plugin manifest lists `ftp`, `rsync`, or `run` under remote access methods
|
||||||
|
- **THEN** platform registry and marketplace responses MUST preserve those methods without exposing host paths, remote credentials, direct sockets, or provider secrets
|
||||||
|
|
||||||
|
#### Scenario: Unsafe remote access declaration is rejected
|
||||||
|
- **WHEN** a plugin manifest includes raw credentials, host paths, direct run socket details, or unknown remote access methods
|
||||||
|
- **THEN** plugin workspace validation and platform registration MUST reject the manifest before it becomes installable
|
||||||
|
|
||||||
|
### Requirement: Remote run operations are capability gated
|
||||||
|
Remote run operations SHALL be represented as explicit run capabilities and SHALL require both the run endpoint and the installed game plugin to declare the requested capability.
|
||||||
|
|
||||||
|
#### Scenario: Declared remote run job is queued
|
||||||
|
- **WHEN** a plugin declares a remote run capability and the selected run endpoint reports the same capability
|
||||||
|
- **THEN** platform MAY queue a bounded job for that server instance using logical target keys and scoped input or artifact refs
|
||||||
|
|
||||||
|
#### Scenario: Undeclared remote run job is denied
|
||||||
|
- **WHEN** a caller requests remote database, RCON, log transfer, or remote file work for a server instance whose plugin did not declare the requested capability
|
||||||
|
- **THEN** platform MUST reject or deny the request before creating a job
|
||||||
|
|
||||||
|
### Requirement: Remote access bridge is platform mediated
|
||||||
|
Plugin pages SHALL request remote access through a platform-mediated bridge action and MUST NOT connect directly to FTP, rsync, run, MySQL, SQLite, log storage, or RCON endpoints.
|
||||||
|
|
||||||
|
#### Scenario: Bridge queues declared remote access
|
||||||
|
- **WHEN** a plugin page has `server.remote.access` permission and requests `remote.access.request` for a declared capability
|
||||||
|
- **THEN** platform MUST authorize the action and queue the corresponding bounded run job
|
||||||
|
|
||||||
|
#### Scenario: Bridge denies undeclared remote access
|
||||||
|
- **WHEN** a plugin page requests `remote.access.request` for a capability not declared by the plugin
|
||||||
|
- **THEN** platform MUST return a safe denial and MUST NOT expose run credentials, host paths, database DSNs, RCON passwords, or remote storage endpoints
|
||||||
|
|
||||||
|
### Requirement: Run handles bounded remote jobs
|
||||||
|
Run SHALL report supported remote capabilities and complete remote job assignments with bounded progress and safe result references while keeping control, job, logs, and artifact channels separate.
|
||||||
|
|
||||||
|
#### Scenario: Run accepts remote database and RCON assignments
|
||||||
|
- **WHEN** run receives declared remote MySQL, SQLite, RCON, log transfer, or remote file assignments
|
||||||
|
- **THEN** run MUST validate bounded job metadata and return terminal results without embedding raw credentials, host paths, query result bodies, log bodies, or RCON output in the job result payload
|
||||||
|
|
||||||
|
#### Scenario: Run rejects unsafe remote job payload
|
||||||
|
- **WHEN** a remote job assignment includes an absolute path, parent traversal, raw secret, direct socket, or oversized inline content
|
||||||
|
- **THEN** run MUST reject the assignment with a bounded failure result
|
||||||
|
|
||||||
|
### Requirement: MC and SCUM plugins share the remote access model
|
||||||
|
First-party Minecraft and SCUM plugin manifests SHALL validate against the same remote access schema and declare only the capabilities each game needs.
|
||||||
|
|
||||||
|
#### Scenario: Minecraft plugin validates
|
||||||
|
- **WHEN** the Minecraft example plugin declares run-managed files, logs, and RCON access
|
||||||
|
- **THEN** plugin workspace manifest validation MUST pass
|
||||||
|
|
||||||
|
#### Scenario: SCUM plugin validates
|
||||||
|
- **WHEN** the SCUM example plugin declares FTP/rsync/run file access, run lifecycle, SQLite/MySQL database read compatibility, log transfer, and RCON access
|
||||||
|
- **THEN** plugin workspace manifest validation MUST pass
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
## 1. OpenSpec Contracts
|
||||||
|
|
||||||
|
- [x] 1.1 Create proposal, design, and spec for plugin-declared remote access.
|
||||||
|
- [x] 1.2 Validate the new change artifacts with `openspec validate --strict`.
|
||||||
|
|
||||||
|
## 2. Plugin Manifest Contracts
|
||||||
|
|
||||||
|
- [x] 2.1 Extend the game plugin manifest schema with `remoteAccess` methods, run capabilities, database engines, RCON, and log transfer declarations.
|
||||||
|
- [x] 2.2 Update manifest validation tests and examples so SCUM and Minecraft validate with remote access declarations.
|
||||||
|
|
||||||
|
## 3. Platform Registry and Authorization
|
||||||
|
|
||||||
|
- [x] 3.1 Add domain and DTO remote access metadata to game plugin, manifest, and marketplace projections.
|
||||||
|
- [x] 3.2 Extend platform validators for remote methods, remote run capabilities, `server.remote.access`, and `remote.access.request`.
|
||||||
|
- [x] 3.3 Gate server-bound jobs against the server instance plugin's declared capabilities.
|
||||||
|
- [x] 3.4 Add bridge execution for declared remote access requests and tests for denied undeclared capabilities.
|
||||||
|
|
||||||
|
## 4. Run Protocol and Worker
|
||||||
|
|
||||||
|
- [x] 4.1 Add remote run capability constants and validation rules for bounded remote assignments.
|
||||||
|
- [x] 4.2 Report remote capabilities from run smoke and worker mode.
|
||||||
|
- [x] 4.3 Add bounded remote job execution results that keep heavy payloads in artifact/log channels.
|
||||||
|
- [x] 4.4 Add run tests for remote DB/RCON/log/file capabilities and unsafe payload rejection.
|
||||||
|
|
||||||
|
## 5. Documentation and Verification
|
||||||
|
|
||||||
|
- [x] 5.1 Update platform, run, and plugin docs for remote access capability declarations.
|
||||||
|
- [x] 5.2 Run plugin manifest validation, platform tests, run tests, and `scripts/check-structure.sh`.
|
||||||
|
- [x] 5.3 Record verification evidence in `tasks.md`.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `openspec validate add-plugin-declared-remote-access --strict` - passed; PostHog telemetry flush logged DNS warnings only after validation succeeded.
|
||||||
|
- `go test ./...` in `platform/` - passed.
|
||||||
|
- `go test ./...` in `run/` - passed.
|
||||||
|
- `npm test` in `plugins/` - passed: 14 manifest/SDK tests.
|
||||||
|
- `npm run typecheck` in `plugins/` - passed.
|
||||||
|
- `TMPDIR=/private/tmp npm run validate:manifest` in `plugins/` - passed for dev, SCUM, and Minecraft manifests.
|
||||||
|
- `scripts/check-structure.sh` - passed.
|
||||||
|
- `git diff --check` - passed.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-13
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The platform already has control, job, log, and artifact channels, plus plugin-declared remote access capabilities for FTP, rsync, run-mediated files, SQL, logs, and RCON. The missing layer is operational delivery: operators need to generate a per-server run executable, download it for a selected OS/architecture, authenticate it with the server's current database-backed run key, update it after it is online, and let plugins declare how run finds and controls the actual game server.
|
||||||
|
|
||||||
|
The old SCUM stack has useful patterns: a run-side process controller, a separate client/robot manager for cases where server control requires a game client or custom automation, Git-based source updates, Go toolchain checks, FTP log polling, SQLite reads, and command forwarding. This design generalizes those patterns without making SCUM special in platform core.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Let server owners generate and download a server-scoped run package from the server list or server action menu.
|
||||||
|
- Store one current run key and one current client-manager key per server/component in the database, encrypted at rest, with redacted secret references in APIs and logs.
|
||||||
|
- Let online run endpoints self-update through a platform job that stages, verifies, swaps, and reports rollback-safe status.
|
||||||
|
- Let plugins declare runtime profiles that explain how to find a server, start/stop it, check dependencies, install missing dependencies, collect live and historical logs, and expose files, FTP/rsync, SQL, and RCON.
|
||||||
|
- Let plugins declare optional client-manager build profiles for cases like SCUM where a separate executable must be compiled from an open repository with a distinct authentication key.
|
||||||
|
- Keep all browser and plugin operations platform-mediated and preserve channel isolation for control, jobs, logs, files, and artifacts.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- No billing, cloud host sales, server rental marketplace, or cloud-provider provisioning workflow.
|
||||||
|
- No raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, direct sockets, or host paths in plugin pages or platform_web responses.
|
||||||
|
- No arbitrary plugin shell scripts for dependency installation or build execution.
|
||||||
|
- No requirement that every game use run; FTP-only, FTP+RCON, and custom-client modes remain valid when declared by the plugin.
|
||||||
|
- No production package-signing infrastructure beyond local checksums/signature hooks in the first implementation.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: Distribution artifacts are secret-bearing platform records
|
||||||
|
|
||||||
|
Platform stores generated run and client-manager packages as artifacts with a distribution record containing kind, server instance, plugin ID, target OS/architecture, source revision, checksum, status, component kind, and key generation. The package config contains the current active key for that server/component because the remote executable must authenticate with it. API responses, logs, job payloads, plugin SDK payloads, and UI state expose only artifact IDs, key generation, and redacted secret refs.
|
||||||
|
|
||||||
|
Alternative considered: ship one global run binary and ask users to hand-edit config files. Rejected because it causes copy/paste key exposure, weak auditability, and poor operator experience.
|
||||||
|
|
||||||
|
### Decision 2: Run and client-manager credentials are separate singleton keys
|
||||||
|
|
||||||
|
Each server/component has exactly one active run key and, when needed, exactly one active client-manager key. Run and client-manager keys remain different secrets, but platform does not keep multiple simultaneously valid keys for the same component. Resetting a key replaces the encrypted database value, increments the key generation, invalidates every older run or client package for that component, and requires regenerating and redeploying the affected package.
|
||||||
|
|
||||||
|
Alternative considered: reuse the run key for plugin client managers. Rejected because client managers may have broader game-specific control surfaces and need independent reset, revocation, and audit.
|
||||||
|
|
||||||
|
Alternative considered: issue temporary enrollment tokens and exchange them for runtime keys. Rejected because the desired operator model is a single database-backed current key per server/component, with explicit reset when trust must be revoked.
|
||||||
|
|
||||||
|
### Decision 3: Plugins declare runtime profiles, not raw local paths
|
||||||
|
|
||||||
|
Plugins define runtime profiles with logical server discovery probes, lifecycle actions, dependency probes, dependency install plans, log sources, transport profiles, and optional client-manager profiles. Operators bind those logical keys to a server instance at setup time. Run resolves bindings locally and returns logical status, not raw host paths.
|
||||||
|
|
||||||
|
Alternative considered: hardcode Minecraft and SCUM discovery rules into platform. Rejected because the platform should remain a game server management core, while game-specific control knowledge belongs in plugins.
|
||||||
|
|
||||||
|
### Decision 4: Dependency install is typed and reviewable
|
||||||
|
|
||||||
|
Dependency checks are read-only probes such as command version, service existence, port availability, file presence under scoped roots, Steam app presence, Java version, Docker availability, or Windows package presence. Install plans are typed steps with approved package managers or verified downloads. Platform shows the plan and queues it only after operator approval.
|
||||||
|
|
||||||
|
Alternative considered: let plugins provide arbitrary install shell scripts. Rejected because it creates an unrestricted execution path and conflicts with run security rules.
|
||||||
|
|
||||||
|
### Decision 5: Client-manager builds are source-pinned build jobs
|
||||||
|
|
||||||
|
Plugin client-manager profiles declare a repository URL, branch/tag or pinned revision policy, supported targets, build system, config template keys, dependency hints, and produced artifact paths. Platform creates a build job in an isolated workspace or future build worker, redacts secrets from logs, writes generated config from secret refs, and publishes a downloadable artifact.
|
||||||
|
|
||||||
|
Alternative considered: require plugin authors to upload prebuilt binaries only. Rejected because SCUM-style managers need reproducible platform-side injection of per-server config and keys.
|
||||||
|
|
||||||
|
### Decision 6: Run owns local execution and transport adapters
|
||||||
|
|
||||||
|
Run is the multi-platform server launcher and transport agent. It starts/stops third-party programs through plugin lifecycle profiles, tails stdout/stderr and declared log files, transfers files through the artifact channel, and performs declared FTP/rsync, SQL, and RCON operations through scoped adapters. Long transfers must not block heartbeat, job ack/result, or log ingest.
|
||||||
|
|
||||||
|
Alternative considered: let platform_web or plugin pages connect directly to FTP/RCON/SQL. Rejected because that exposes credentials and bypasses platform authorization, audit, and channel isolation.
|
||||||
|
|
||||||
|
### Decision 7: Live and historical logs share durable sequence semantics
|
||||||
|
|
||||||
|
Run streams live process output and file tails through the log channel with source IDs and monotonically increasing sequences. Historical logs are fetched through backfill jobs that use checkpoints, file fingerprints, FTP polling cursors, or database cursors to avoid duplicates. Platform_web displays live tail and history through platform APIs only.
|
||||||
|
|
||||||
|
Alternative considered: return log bodies inside job results. Rejected because logs can be large and must not compete with control and job metadata.
|
||||||
|
|
||||||
|
### Decision 8: Self-update is staged and rollback-safe
|
||||||
|
|
||||||
|
When run is online and supports the update capability, platform queues an update job with an artifact ref and checksum. Run downloads via the artifact channel, verifies checksum/signature metadata, stages the new binary, drains or rejects new local work, swaps atomically where supported, restarts, and reports success or rollback failure. Offline endpoints keep the latest downloadable package for manual replacement.
|
||||||
|
|
||||||
|
Alternative considered: send a raw command to pull the latest repository. Rejected because it is platform-specific, hard to verify, and unsafe to audit.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] Build jobs can execute untrusted repository code. Mitigation: require plugin-declared build profiles, pinned refs, isolated workspaces, bounded logs, and a later sandboxed build worker before public plugin builds.
|
||||||
|
- [Risk] Self-update can leave a remote run offline. Mitigation: stage artifacts, verify checksums, retain previous binary, report last-known version, and keep manual download available.
|
||||||
|
- [Risk] Dependency install plans differ across Windows, Linux, and macOS. Mitigation: model probes and install steps per target platform, and show unsupported targets before dispatch.
|
||||||
|
- [Risk] Historical log backfill can duplicate records. Mitigation: persist per-source checkpoints with file fingerprints, offsets, sequence acknowledgements, and cursor metadata.
|
||||||
|
- [Risk] Resetting a key immediately breaks deployed run or client-manager binaries. Mitigation: make reset confirmations explicit, mark old packages revoked, show that regeneration is required, and keep online update/manual download paths visible.
|
||||||
|
- [Risk] Operators may expect plugin profiles to work without binding server-specific values. Mitigation: require server setup validation before showing actions that depend on missing bindings.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Extend plugin manifest schema and examples with runtime profiles, dependency probes, log sources, transport profiles, and client-manager build profiles.
|
||||||
|
2. Add platform domain, model, DTO, validator, and service support for distribution records, encrypted database key storage, key reset, key generation checks, build jobs, dependency status, runtime bindings, and update jobs.
|
||||||
|
3. Add run package config loading, registration authentication against the current database key generation, dependency probes, discovery probes, log source checkpoints, transport adapter envelopes, and self-update executor.
|
||||||
|
4. Add platform_web server-list and server-detail action menus for generate/download/update run, generate/download client manager, dependency checks, live logs, and historical logs.
|
||||||
|
5. Add tests and docs across plugins, platform, run, and platform_web; validate with OpenSpec, structure checks, backend tests, plugin validation, run tests, frontend tests, and browser walkthroughs for touched UI.
|
||||||
|
|
||||||
|
Rollback removes the new distribution/client-manager routes and UI actions, leaves existing run endpoints and plugin-declared remote access behavior intact, and preserves already-created artifacts as inert downloadable records until manually cleaned.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which envelope-key source should protect the encrypted key columns stored in the platform database?
|
||||||
|
- Should platform-side builds run in the platform process for the first version, or require a separate build worker from day one?
|
||||||
|
- What default retention should apply to historical logs and generated binaries?
|
||||||
|
- Which target packaging formats should be first-class first: zip/tar.gz only, or Windows service installer, systemd unit bundle, and launchd plist bundles?
|
||||||
|
- Should plugin client-manager profiles support private repositories later, and if so how should source credentials be stored and audited?
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Run is becoming the platform's cross-platform server launcher and transport agent, but operators still need a safe way to generate, download, authenticate, update, and observe per-server run binaries. Some games also need plugin-declared companion clients, such as SCUM-style client managers, that are built from plugin-provided source repositories with separate credentials and lifecycle from run.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a platform-managed run distribution workflow from the server list or server actions menu: generate executor, choose target OS/architecture, write the current server/component authentication key into the generated package config, download the artifact, and store the single active encrypted key in the database.
|
||||||
|
- Add run self-update orchestration: if an assigned run is already online, platform can enqueue an update command that instructs the remote run to pull or replace itself with the latest approved run build.
|
||||||
|
- Add plugin-declared external controller/client-manager build profiles for games that cannot be controlled only through run, FTP, SQL, logs, and RCON.
|
||||||
|
- Add separate database-backed active keys for run and plugin-declared client managers; resetting a server's run or client key invalidates every previous package and requires regenerating the corresponding run or client artifact.
|
||||||
|
- Extend plugin manifests with server discovery, dependency checks, dependency install guidance, log source declarations, historical log retention hints, and transport wiring so run knows how to find and manage the target server without hardcoded game logic.
|
||||||
|
- Add UI/API contracts for server action menu entries: generate run, download latest run package, push run update, generate plugin client manager, inspect dependency status, open live logs, and browse historical logs.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `run-distribution-and-client-managers`: Platform-managed run packaging, download, key provisioning, remote self-update, plugin-declared companion client builds, dependency checks, server discovery, and live/historical observability.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects `plugins/` manifest schema, SDK docs, SCUM and Minecraft example manifests, and validation tests.
|
||||||
|
- Affects `platform/` domain, DTOs, encrypted secret references, build records, artifact records, run endpoint provisioning, job creation, audit events, and API routes.
|
||||||
|
- Affects `run/` generated package config, updater job handling, dependency probes, server discovery adapters, live log tailing, historical log checkpoints, file/FTP/SQL/RCON transport adapters, and docs.
|
||||||
|
- Affects `platform_web/` server list and server detail actions, live log views, historical log views, dependency status surfaces, and browser walkthrough coverage.
|
||||||
|
- Does not add billing, host rental, cloud host sales, raw credential exposure, plugin-owned direct sockets, or unrelated SaaS marketplace workflows.
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Platform generates server-scoped run packages
|
||||||
|
The platform SHALL let an authorized operator generate a run package for a selected server instance and target platform using the server's single current run key stored encrypted in the platform database.
|
||||||
|
|
||||||
|
#### Scenario: Operator generates run from server actions
|
||||||
|
- **WHEN** an authorized operator selects generate executor for a server instance and chooses a supported OS/architecture
|
||||||
|
- **THEN** platform MUST create or reuse the server's current encrypted run key, write that key into the generated package config, create a distribution record with key generation, build/download artifact, checksum metadata, and audit event, and MUST NOT return the raw key in the API response
|
||||||
|
|
||||||
|
#### Scenario: Operator downloads generated run package
|
||||||
|
- **WHEN** an authorized operator downloads a generated run artifact
|
||||||
|
- **THEN** platform MUST authorize access by server scope and return the secret-bearing package through artifact/download APIs without exposing raw host paths, direct sockets, or database credentials in package metadata
|
||||||
|
|
||||||
|
### Requirement: Run and client-manager keys are isolated singletons
|
||||||
|
Run executors and plugin-declared client managers SHALL use different authentication secrets, and each server/component SHALL have exactly one current active key stored encrypted in the platform database.
|
||||||
|
|
||||||
|
#### Scenario: Client manager is generated after run
|
||||||
|
- **WHEN** a plugin-declared client-manager package is generated for a server that already has a run package
|
||||||
|
- **THEN** platform MUST create or reuse the server's current encrypted client-manager key and MUST NOT reuse, reveal through API metadata, or derive it from the run key
|
||||||
|
|
||||||
|
#### Scenario: Component key reset is requested
|
||||||
|
- **WHEN** an operator resets a server's run key or client-manager key
|
||||||
|
- **THEN** platform MUST replace the encrypted database key for that component, increment the key generation, revoke all packages generated with prior generations, and record an audit event identifying the component kind without logging raw key material
|
||||||
|
|
||||||
|
#### Scenario: Old package authenticates after reset
|
||||||
|
- **WHEN** a run or client-manager package generated before the latest key reset attempts to authenticate
|
||||||
|
- **THEN** platform MUST reject the old key and require the operator to regenerate and redeploy the corresponding run or client-manager package
|
||||||
|
|
||||||
|
### Requirement: Plugins declare runtime profiles
|
||||||
|
Game plugins SHALL declare runtime profiles that describe how run or external transports can discover, control, observe, and connect to the game server using logical keys instead of raw credentials or host paths.
|
||||||
|
|
||||||
|
#### Scenario: Plugin declares server discovery and transports
|
||||||
|
- **WHEN** a plugin manifest declares runtime profiles with discovery probes, lifecycle actions, dependency probes, log sources, FTP/rsync, SQL, RCON, or client-manager bindings
|
||||||
|
- **THEN** plugin validation MUST accept only known profile fields and platform MUST preserve the declarations for server setup and action gating
|
||||||
|
|
||||||
|
#### Scenario: Plugin manifest includes unsafe runtime details
|
||||||
|
- **WHEN** a plugin manifest embeds raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, absolute host paths, direct sockets, or arbitrary shell scripts in runtime profiles
|
||||||
|
- **THEN** plugin validation and platform registration MUST reject the manifest before it becomes installable
|
||||||
|
|
||||||
|
### Requirement: Server runtime bindings resolve plugin profiles
|
||||||
|
Server instances SHALL bind plugin-declared logical runtime keys to operator-provided local or remote settings before run-dependent actions are enabled.
|
||||||
|
|
||||||
|
#### Scenario: Run discovers an existing server
|
||||||
|
- **WHEN** run registers for a server instance with complete runtime bindings and the plugin declares discovery probes
|
||||||
|
- **THEN** run MUST evaluate the probes locally, report logical discovery status, version, and health, and MUST NOT return raw host paths or secret values to platform_web or plugin pages
|
||||||
|
|
||||||
|
#### Scenario: Required binding is missing
|
||||||
|
- **WHEN** an operator opens server actions for a runtime profile that requires unbound install root, service name, FTP profile, SQL profile, RCON profile, or client-manager profile
|
||||||
|
- **THEN** platform MUST mark dependent actions unavailable and explain the missing logical binding without exposing internal storage details
|
||||||
|
|
||||||
|
### Requirement: Plugins control third-party startup behavior
|
||||||
|
Third-party game program startup and shutdown SHALL be driven by plugin-declared lifecycle profiles and server runtime bindings, not hardcoded platform game logic.
|
||||||
|
|
||||||
|
#### Scenario: Local process server starts through run
|
||||||
|
- **WHEN** a plugin declares a local process lifecycle profile and the server runtime binding is complete
|
||||||
|
- **THEN** run MUST start or stop the third-party program through the declared bounded action template and stream stdout/stderr through the log channel
|
||||||
|
|
||||||
|
#### Scenario: Hosted server has no local lifecycle control
|
||||||
|
- **WHEN** a plugin declares an FTP-only, FTP+RCON, or custom-client runtime profile without local process lifecycle
|
||||||
|
- **THEN** platform MUST hide or deny local start/stop actions and expose only the declared remote or client-manager actions
|
||||||
|
|
||||||
|
### Requirement: Dependency checks and installs are typed
|
||||||
|
Run SHALL check dependencies through plugin-declared probes and SHALL install missing dependencies only through approved, typed, reviewable install plans.
|
||||||
|
|
||||||
|
#### Scenario: Dependency check reports missing runtime
|
||||||
|
- **WHEN** run evaluates plugin-declared dependency probes and a required runtime, service, package, toolchain, Steam app, Java runtime, Docker runtime, or OS package is missing
|
||||||
|
- **THEN** platform MUST show dependency status and a reviewable install plan if the plugin declares one for the target platform
|
||||||
|
|
||||||
|
#### Scenario: Dependency install is approved
|
||||||
|
- **WHEN** an authorized operator approves a dependency install plan
|
||||||
|
- **THEN** platform MUST queue a bounded run job using typed package/download steps, checksum or source metadata where provided, and must reject arbitrary shell snippets
|
||||||
|
|
||||||
|
### Requirement: Client-manager packages are plugin-declared builds
|
||||||
|
The platform SHALL support plugin-declared client-manager build profiles for companion executables that require source checkout, configuration injection, and compilation before download.
|
||||||
|
|
||||||
|
#### Scenario: SCUM-style client manager is generated
|
||||||
|
- **WHEN** a plugin declares a client-manager build profile with repository, revision policy, supported target platform, build system, config template, and output artifact paths
|
||||||
|
- **THEN** platform MUST create a build job that checks out the approved source, injects redacted configuration from secret refs, produces a downloadable artifact, and redacts secrets from build logs
|
||||||
|
|
||||||
|
#### Scenario: Unsupported client-manager target is requested
|
||||||
|
- **WHEN** an operator requests a client-manager build for an OS/architecture not declared by the plugin profile
|
||||||
|
- **THEN** platform MUST reject the request before cloning source or creating a credential
|
||||||
|
|
||||||
|
### Requirement: Online run endpoints self-update through platform jobs
|
||||||
|
The platform SHALL update online run endpoints by queuing a bounded self-update job that references an approved artifact and checksum instead of sending raw shell commands.
|
||||||
|
|
||||||
|
#### Scenario: Online run accepts update
|
||||||
|
- **WHEN** an assigned run endpoint is online and supports self-update
|
||||||
|
- **THEN** platform MUST queue an update job, and run MUST download the artifact, verify checksum or signature metadata, stage the replacement, report progress, and restart or swap only after verification succeeds
|
||||||
|
|
||||||
|
#### Scenario: Update verification fails
|
||||||
|
- **WHEN** run cannot verify or stage the update artifact
|
||||||
|
- **THEN** run MUST keep the current executable, report a bounded failure result, and preserve heartbeat or last-known status without leaking local paths or credentials
|
||||||
|
|
||||||
|
### Requirement: Run exposes multi-channel transport capabilities
|
||||||
|
Run SHALL provide declared server operations through separate control, job, log, artifact/file, FTP/rsync, SQL, and RCON channels or adapters while preserving priority and bounded payload rules.
|
||||||
|
|
||||||
|
#### Scenario: Long file transfer is active
|
||||||
|
- **WHEN** run is transferring a large file, FTP payload, rsync payload, or artifact chunk
|
||||||
|
- **THEN** heartbeat, job ack/result, cancellation polling, and log ingest MUST continue independently without waiting for transfer completion
|
||||||
|
|
||||||
|
#### Scenario: Undeclared transport operation is requested
|
||||||
|
- **WHEN** a plugin page or platform caller requests FTP, SQL, RCON, file, or process work not declared by the plugin runtime profile and run capabilities
|
||||||
|
- **THEN** platform MUST deny the request before creating a job and MUST return only a safe error
|
||||||
|
|
||||||
|
### Requirement: Live and historical logs are platform-mediated
|
||||||
|
Platform_web SHALL show live and historical server logs through platform APIs backed by run log ingest, log checkpoints, and plugin-declared log sources.
|
||||||
|
|
||||||
|
#### Scenario: Live log tail is opened
|
||||||
|
- **WHEN** an operator opens live logs for a server with an online run endpoint
|
||||||
|
- **THEN** platform_web MUST read from platform log APIs and display sequenced log entries from run without direct run sockets or raw file paths
|
||||||
|
|
||||||
|
#### Scenario: Historical log backfill is requested
|
||||||
|
- **WHEN** an operator requests older logs for a declared process, file, FTP, SQL, or plugin-specific log source
|
||||||
|
- **THEN** platform MUST queue or serve a backfill using per-source checkpoints, cursors, or file fingerprints and MUST avoid embedding large log bodies in job result payloads
|
||||||
|
|
||||||
|
### Requirement: Server action menu reflects declared availability
|
||||||
|
The server list and server detail action menu SHALL show generate run, download run, push run update, generate client manager, dependency check, install dependency, live logs, and historical logs only when the current user and plugin/runtime state permit them.
|
||||||
|
|
||||||
|
#### Scenario: Actions are available
|
||||||
|
- **WHEN** a server has a plugin with runtime profiles, complete bindings, and the current user has the required permissions
|
||||||
|
- **THEN** platform_web MUST render the applicable actions using the existing game-operations console style and call platform APIs rather than plugin-owned direct endpoints
|
||||||
|
|
||||||
|
#### Scenario: Actions are unavailable
|
||||||
|
- **WHEN** a server lacks a required plugin declaration, binding, online run status, build profile, or user permission
|
||||||
|
- **THEN** platform_web MUST hide or disable the action with a safe reason and MUST NOT render raw credentials, host paths, direct sockets, or secret refs beyond redacted identifiers
|
||||||
|
|
||||||
|
### Requirement: Distribution and control events are audited
|
||||||
|
The platform SHALL audit run generation, run download, credential reset, run update, dependency install, client-manager build, client-manager download, and transport operation requests.
|
||||||
|
|
||||||
|
#### Scenario: Sensitive operation completes
|
||||||
|
- **WHEN** a sensitive distribution or control operation succeeds, fails, or is denied
|
||||||
|
- **THEN** platform MUST record actor, server instance, plugin ID, component kind, operation, result, artifact ID or job ID where applicable, and redacted reason without raw secret material
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
## 1. Plugin Contracts
|
||||||
|
|
||||||
|
- [x] 1.1 Extend the game plugin manifest schema with runtime profiles for server discovery, lifecycle profiles, dependency probes, install plans, log sources, transport profiles, and client-manager build profiles.
|
||||||
|
- [x] 1.2 Update plugin SDK types and bridge docs for runtime bindings, run distribution actions, dependency actions, live/historical log actions, and client-manager generation.
|
||||||
|
- [x] 1.3 Update SCUM and Minecraft example manifests to declare realistic runtime profiles, dependency probes, log sources, and transport bindings.
|
||||||
|
- [x] 1.4 Add plugin validation tests that accept safe runtime/client-manager profiles and reject raw keys, database DSNs, RCON passwords, direct sockets, raw host paths, and arbitrary shell snippets.
|
||||||
|
|
||||||
|
## 2. Platform Secret and Distribution Model
|
||||||
|
|
||||||
|
- [x] 2.1 Add platform domain, DTO, model, repository, and validator types for run distributions, client-manager distributions, runtime bindings, encrypted database keys, key generations, dependency status, build jobs, and update jobs.
|
||||||
|
- [x] 2.2 Implement encrypted-at-rest database key storage for exactly one active run key and one active client-manager key per server/component.
|
||||||
|
- [x] 2.3 Add key reset services and tests proving reset replaces the active encrypted key, increments generation, revokes old packages, and requires regenerating the affected run or client-manager artifact.
|
||||||
|
- [x] 2.4 Add services and tests for generating server-scoped run packages, creating artifact records, writing the current run key into package config, storing key generation metadata, and authorizing downloads.
|
||||||
|
- [x] 2.5 Add services and tests for client-manager build records, distinct current client-manager keys, source revision metadata, target-platform validation, and downloadable artifact publication.
|
||||||
|
- [x] 2.6 Add audit events for generation, download, key reset, update, dependency install, build, and denied sensitive operations.
|
||||||
|
|
||||||
|
## 3. Platform APIs and Job Orchestration
|
||||||
|
|
||||||
|
- [x] 3.1 Add API routes and handlers for server action availability, generate/download run, reset run key, push run update, generate/download client manager, reset client-manager key, check dependencies, install dependencies, live logs, and historical logs.
|
||||||
|
- [x] 3.2 Gate all routes by user permissions, server ownership, plugin declarations, runtime bindings, and run endpoint capability availability.
|
||||||
|
- [x] 3.3 Add self-update job creation with artifact refs, checksums, idempotency keys, and denied-path tests for offline or unsupported run endpoints.
|
||||||
|
- [x] 3.4 Add dependency-check and dependency-install job creation with typed install plans and rejection of arbitrary shell commands.
|
||||||
|
- [x] 3.5 Add historical log backfill job creation with source IDs, checkpoints, bounded results, and artifact/log channel separation.
|
||||||
|
|
||||||
|
## 4. Run Bootstrap and Runtime Behavior
|
||||||
|
|
||||||
|
- [x] 4.1 Implement generated package config loading, current key generation authentication, server-scoped identity, old-package rejection after reset, and redacted local diagnostics.
|
||||||
|
- [x] 4.2 Implement runtime profile resolution for local process, hosted FTP/RCON, FTP-only, SQL, file, and custom-client modes using server bindings.
|
||||||
|
- [x] 4.3 Implement dependency probes and typed install plan execution for supported OS targets with safe progress and failure results.
|
||||||
|
- [x] 4.4 Implement self-update executor with artifact download, checksum/signature verification hooks, staging, rollback, restart/swap behavior, and tests.
|
||||||
|
- [x] 4.5 Implement live log tailing from process stdout/stderr and declared file sources with durable sequence checkpoints.
|
||||||
|
- [x] 4.6 Implement historical log backfill cursors for declared file, FTP, SQL, and plugin-specific log sources without embedding large log bodies in job results.
|
||||||
|
- [x] 4.7 Implement bounded adapter envelopes for FTP/rsync, SQL read, RCON command, and file transfer so long transfers do not block heartbeat or job metadata.
|
||||||
|
|
||||||
|
## 5. Build Pipeline
|
||||||
|
|
||||||
|
- [x] 5.1 Implement platform-side or build-worker source checkout for plugin-declared client-manager repositories with pinned revision metadata.
|
||||||
|
- [x] 5.2 Implement target-platform validation, build dependency checks, redacted config injection, bounded build logs, checksum calculation, and artifact publication.
|
||||||
|
- [x] 5.3 Add SCUM-style client-manager build tests covering supported targets, unsupported target denial, separate credential injection, and redacted logs.
|
||||||
|
|
||||||
|
## 6. platform_web Workflows
|
||||||
|
|
||||||
|
- [x] 6.1 Add server list and server detail action menu entries for generate run, download run, push run update, generate client manager, dependency check/install, live logs, and historical logs.
|
||||||
|
- [x] 6.2 Add API client types, schemas, and tests for run distribution, client-manager distribution, dependency status, update jobs, and historical log backfill.
|
||||||
|
- [x] 6.3 Add UI states for unavailable actions with safe reasons, redacted secret refs, run online/offline status, build status, update progress, dependency status, and log backfill status.
|
||||||
|
- [x] 6.4 Preserve the existing platform_web game-operations console style and verify no raw keys, host paths, sockets, DSNs, RCON passwords, or direct run endpoints render in the UI.
|
||||||
|
|
||||||
|
## 7. Documentation and Verification
|
||||||
|
|
||||||
|
- [x] 7.1 Update platform, run, plugin, and platform_web docs for run generation, client-manager builds, runtime bindings, dependency profiles, live logs, historical logs, and self-update behavior.
|
||||||
|
- [x] 7.2 Run plugin manifest validation, plugin SDK tests, platform tests, run tests, frontend tests, and `scripts/check-structure.sh`.
|
||||||
|
- [x] 7.3 Run `openspec validate add-run-distribution-and-client-managers --strict`.
|
||||||
|
- [x] 7.4 Complete a browser walkthrough for touched server-list and server-detail workflows before marking UI acceptance complete.
|
||||||
|
- [x] 7.5 Record verification evidence in this task file before completion.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- `cd plugins && npm run validate:manifest`: passed. First sandbox attempt failed with `listen EPERM` on the local `tsx` IPC pipe, then the same command passed with approved escalation.
|
||||||
|
- `cd plugins && npm run typecheck`: passed.
|
||||||
|
- `cd plugins && npm test`: passed, 1 file / 17 tests.
|
||||||
|
- `cd run && go test ./...`: passed.
|
||||||
|
- `cd platform && go test ./...`: passed.
|
||||||
|
- `cd platform_web && npm run typecheck`: passed.
|
||||||
|
- `cd platform_web && npm test`: passed, 13 files / 60 tests.
|
||||||
|
- `cd platform_web && npm run build`: passed, Vite production build completed.
|
||||||
|
- `scripts/check-structure.sh`: passed.
|
||||||
|
- `openspec validate add-run-distribution-and-client-managers --strict`: passed. The CLI printed a PostHog network flush warning after validation, but exited successfully with `Change 'add-run-distribution-and-client-managers' is valid`.
|
||||||
|
- `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh`: passed.
|
||||||
|
- Browser evidence file: `/private/tmp/browser-local-debug-acceptance/browser-acceptance/browser-acceptance-evidence.json`.
|
||||||
|
- Browser walkthrough evidence covered 首页、服务器管理、服务器管理 / 运行操作菜单、插件市场、用户管理、AI 提供商管理、服务器详情、服务器详情 / 插件控制, plus desktop/mobile checks for black mecha and magical-girl themes.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-12
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The platform web console already includes the required first-party routes for overview, server management, plugin marketplace, user management, and AI provider management. The current pages are uneven: some are data-backed, some fall back to local demonstration data, and several management surfaces expose create or status actions without complete edit, deletion, retirement, or operational error handling.
|
||||||
|
|
||||||
|
This change completes the management console without changing the product scope. Browser code continues to call platform APIs only. AI provider keys remain owned by `platform/`. Run internals, host paths, raw credentials, and direct sockets remain outside `platform_web/` and plugin pages.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Make visible management data come from platform APIs in normal operation, with explicit empty/error states when APIs are unavailable.
|
||||||
|
- Complete user management for editing profile/contact fields, roles, status, and removal or deactivation.
|
||||||
|
- Complete server management for metadata edits, safe delete/archive, administrator assignment, lifecycle feedback, and detail refresh.
|
||||||
|
- Complete plugin marketplace behavior for real API data, state actions, detail refresh, and no production demo fallback masking failures.
|
||||||
|
- Complete AI provider management for correct empty-list behavior, create/update/status/test/model refresh, and deletion or disable-only retirement semantics.
|
||||||
|
- Keep frontend DTOs, validators, route contracts, and shared view contracts outside page components.
|
||||||
|
- Preserve the platform_web theme and visual system while making controls dense enough for routine operations.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- No billing, cloud host sales, provider marketplace, or agent-provider/cloud-provider workflow.
|
||||||
|
- No direct plugin-to-run access and no browser access to run credentials, host paths, or raw sockets.
|
||||||
|
- No raw AI key entry or display in `platform_web`; the UI continues to work with secret references only.
|
||||||
|
- No replacement of the hash router, design system, or global theme architecture.
|
||||||
|
- No real game process orchestration beyond existing platform-mediated workflows.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### API inventory and removal semantics
|
||||||
|
|
||||||
|
- Users: existing `GET/POST /api/v1/users` and `GET/PUT /api/v1/users/{id}` cover list, create, edit, role updates, status updates, and safe removal by deactivation. Hard delete is not exposed; the UI labels removal as `停用` and sends `status=disabled`.
|
||||||
|
- Server instances: existing list/create/get/lifecycle/admin APIs are extended with `PUT /api/v1/server-instances/{id}` for metadata edits and `DELETE /api/v1/server-instances/{id}` for safe archive. Archive marks the server `deleted`, rejects running/installing states, hides deleted servers from normal lists, and returns them only with `state=deleted`.
|
||||||
|
- Server administrators: `GET /administrators/candidates`, `POST /administrators`, and `DELETE /administrators/{userId}` remain membership APIs; they never delete user accounts.
|
||||||
|
- Plugin marketplace: `GET /plugin-marketplace/plugins`, `GET /plugin-marketplace/plugins/{id}`, and `POST /plugin-marketplace/plugins/{id}/state` are the source of list/detail/action state. The page no longer maps `pluginCatalog` into runtime fallback data.
|
||||||
|
- AI providers: `GET/POST /ai-providers`, `GET/PUT /ai-providers/{id}`, `POST /ai-providers/{id}/status`, `POST /test`, and `GET /models` cover empty lists, save, enable/disable, metadata test, model refresh, and retirement. Retirement is disable-only (`status=disabled`) and raw key material remains rejected by validators.
|
||||||
|
- Config/logs/run/artifacts/audit: management pages continue using platform-mediated DTOs. Config API failure renders an explicit unavailable state instead of local sample config.
|
||||||
|
|
||||||
|
Missing backend support identified during implementation was limited to server instance metadata update and archive/list semantics; these were added with DTO, service, validator, repository, route, documentation, and API tests.
|
||||||
|
|
||||||
|
Request/response shapes remain bounded to IDs, logical refs, redacted secret refs, and platform-owned metadata. They do not include raw AI keys, raw host paths, run credentials, direct socket details, or unrestricted plugin execution fields.
|
||||||
|
|
||||||
|
### Decision 1: Treat demo data as development-only fixtures
|
||||||
|
|
||||||
|
Production pages will not silently replace failed API calls with `pluginCatalog`, `userAccess`, `seedProviders`, or `fallbackConfig`. Development fixtures may remain for isolated tests or explicit local-auth fallback paths, but page status must clearly show API unavailable or empty data.
|
||||||
|
|
||||||
|
Alternative considered: keep local fallback data for visual continuity. Rejected because it hides missing backend behavior and makes the console appear functional when data was not loaded.
|
||||||
|
|
||||||
|
### Decision 2: Add typed API contracts before page work
|
||||||
|
|
||||||
|
Any missing update, delete, retire, archive, or detail-refresh behavior will be represented first in `platform_web/api/types.ts`, `platform_web/api/client.ts`, shared contracts, and schemas. Page components will consume those typed contracts rather than defining request or response shapes inline.
|
||||||
|
|
||||||
|
Alternative considered: implement form submit handlers directly against ad hoc endpoints. Rejected because the repository requires API clients, DTOs, contracts, and schemas to stay outside page components.
|
||||||
|
|
||||||
|
### Decision 3: Use deactivation or archive when hard deletion is unsafe
|
||||||
|
|
||||||
|
Delete-like UI must respect domain safety. Users can be disabled or deleted according to platform rules. AI providers can be disabled or retired if deletion would break references. Server instances can be archived or soft-deleted if a running server or existing history prevents hard deletion. The UI must name the action accurately.
|
||||||
|
|
||||||
|
Alternative considered: add one generic delete button everywhere. Rejected because operational resources have different safety and audit requirements.
|
||||||
|
|
||||||
|
### Decision 4: Make fallback and failure states auditable
|
||||||
|
|
||||||
|
Pages that cannot load API data must show an error or empty state with retry and diagnostic context. Development-only fixtures must be visually isolated and must not enable state-changing actions that imply persistence.
|
||||||
|
|
||||||
|
Alternative considered: keep current optimistic local writes after failed API calls. Rejected because it can produce false success for management actions.
|
||||||
|
|
||||||
|
### Decision 5: Verify UI workflows in browser after implementation
|
||||||
|
|
||||||
|
Because this change touches frontend pages and interactions, completion requires automated tests plus a browser walkthrough. The walkthrough must include the edited pages at desktop and narrow widths and must confirm that no raw secrets, host paths, run credentials, or direct socket values are visible.
|
||||||
|
|
||||||
|
Alternative considered: rely only on unit tests. Rejected because these workflows depend on visible controls, responsive layout, and operational feedback states.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] Backend APIs may not yet expose every edit/delete operation. Mitigation: implement missing platform DTO, service, repository, handler, and tests in the same change before wiring the UI.
|
||||||
|
- [Risk] Removing silent fallbacks can make local development feel less populated. Mitigation: keep explicit local debug fixtures behind development-only paths and show clear labels when they are active.
|
||||||
|
- [Risk] Delete semantics can vary by resource. Mitigation: define resource-specific action labels and confirmation copy, and prefer disable/archive where hard deletion is unsafe.
|
||||||
|
- [Risk] This change spans several pages. Mitigation: implement one resource workflow at a time with focused tests, then finish with a full browser walkthrough and structure check.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Inventory current platform API coverage for users, server instances, marketplace plugins, AI providers, config, and maintenance data.
|
||||||
|
2. Add or extend backend routes and service behavior where required for edit, delete, archive, retire, and detail refresh operations.
|
||||||
|
3. Update frontend API types, schemas, and shared contracts before page components.
|
||||||
|
4. Replace silent demo fallbacks with explicit empty/error/local-development states.
|
||||||
|
5. Implement page-level controls, confirmations, operation feedback, and tests per resource.
|
||||||
|
6. Run frontend tests/build, relevant backend tests, browser walkthrough, `scripts/check-structure.sh`, and strict OpenSpec validation.
|
||||||
|
|
||||||
|
Rollback is contained before downstream changes depend on these workflows: remove the new routes/client methods, restore previous page interactions, and keep read-only views. After users rely on edit/archive actions, rollback should be handled by a new OpenSpec change with data compatibility notes.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Resolved: server instance removal is named archive in the UI and implemented as `DELETE` to a `deleted` state so history remains visible by explicit filter.
|
||||||
|
- Resolved: AI provider removal is disable-only retirement for this implementation.
|
||||||
|
- Resolved: user removal is deactivation (`disabled`) for this implementation; hard delete is not exposed.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- `go test ./...` from `platform/` passed.
|
||||||
|
- `cd platform_web && npm run typecheck && npm run test && npm run build` passed.
|
||||||
|
- `scripts/browser-acceptance.sh` passed, covering users, servers, plugin marketplace, AI providers, server detail, plugin controls, desktop/mobile widths, black-mecha/magical-girl themes, and forbidden-fragment scans. Evidence: `.local-debug/browser-acceptance/browser-acceptance-evidence.json`.
|
||||||
|
- `scripts/check-structure.sh` passed.
|
||||||
|
- `openspec validate complete-platform-web-management-workflows --strict` passed. The CLI emitted PostHog telemetry flush network errors after the success line because external network access is unavailable.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The platform web console now has the required first-party pages, but several management workflows still depend on local demonstration data or stop at create/status-only interactions. Operators need data-backed editing, deletion, and failure-visible empty states so the console can be used as a real game server operations workspace instead of a partially mocked prototype.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Replace page-visible demo fallbacks for users, AI providers, plugin marketplace data, and server configuration with explicit API-backed loading, empty, and error states.
|
||||||
|
- Complete user management with edit, role/profile update, status change, and deletion or deactivation workflows backed by platform APIs.
|
||||||
|
- Complete server management with metadata edit actions such as rename, ownership/admin changes, safe delete/archive, and clear lifecycle/history feedback without exposing run internals.
|
||||||
|
- Complete plugin marketplace operations with real empty/error states, safe install/enable/disable handling, plugin detail refresh, and no production reliance on `pluginCatalog` fallback data.
|
||||||
|
- Complete AI provider management with reliable list-empty behavior, create/update/status/test/model refresh workflows, and deletion or disable-only retirement semantics that preserve secret boundaries.
|
||||||
|
- Keep the existing platform_web magical game operations visual direction and role-scoped navigation while making pages dense, operational, and browser-verifiable.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `platform-web-management-completion`: Data-backed platform_web management workflows for users, servers, plugins, AI providers, and related operational states.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None. This change builds on existing workflow specs and adds a frontend completion contract rather than modifying archived requirement files.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects `platform_web/` API types/client methods, page contracts, schemas, server/user/plugin/AI provider pages, stores, tests, and browser acceptance coverage.
|
||||||
|
- May require `platform/` DTOs, validators, services, repositories, and HTTP routes where current APIs do not support edit/delete/retire operations.
|
||||||
|
- May require updates to frontend structure checks if new shared contract/schema directories or rules are introduced.
|
||||||
|
- Must not add billing, cloud host sales, agent-provider/cloud-provider workflows, unrelated SaaS marketplace features, raw AI key exposure, raw host path exposure, direct run sockets, or plugin access to platform secrets.
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Management pages use API-backed data states
|
||||||
|
The platform web management pages SHALL use platform APIs as the source of visible operational data and SHALL render explicit loading, empty, error, or development-fixture states instead of silently substituting production data with local examples.
|
||||||
|
|
||||||
|
#### Scenario: API returns an empty list
|
||||||
|
- **WHEN** a management page API returns an empty list for users, servers, plugins, AI providers, logs, audit events, or run endpoints
|
||||||
|
- **THEN** the page MUST render an empty state that reflects the empty API response and MUST NOT keep previously seeded demonstration rows visible
|
||||||
|
|
||||||
|
#### Scenario: API request fails
|
||||||
|
- **WHEN** a management page API request fails in normal operation
|
||||||
|
- **THEN** the page MUST render an error state with retry affordance or diagnostic context and MUST NOT enable persistence-looking actions against local fallback data
|
||||||
|
|
||||||
|
#### Scenario: Development fixture is active
|
||||||
|
- **WHEN** an explicit development fixture or local-auth fallback is active
|
||||||
|
- **THEN** the page MUST label the data as local development data and MUST disable or clearly reject state-changing actions that cannot be persisted
|
||||||
|
|
||||||
|
### Requirement: Management list pages preserve full-width work surfaces
|
||||||
|
The platform web management pages SHALL keep list, grid, and table views as full-width work surfaces and SHALL put create, edit, and detail workflows in modal, drawer, or detail-route surfaces instead of permanent side panes or inline split forms.
|
||||||
|
|
||||||
|
#### Scenario: Operator opens create, edit, or detail workflow
|
||||||
|
- **WHEN** an operator opens create, edit, or detail workflows on users, plugin marketplace, AI providers, servers, or similar management list pages
|
||||||
|
- **THEN** the page MUST keep the underlying list, grid, or table full-width and MUST render the workflow in a modal, drawer, or detail route without a permanent right-side form/detail pane
|
||||||
|
|
||||||
|
### Requirement: User management supports full account maintenance
|
||||||
|
The user management page SHALL allow authorized platform administrators to create users, edit user identity/contact fields, update roles, update status, and remove or deactivate users through platform APIs.
|
||||||
|
|
||||||
|
#### Scenario: Administrator edits user fields
|
||||||
|
- **WHEN** a platform administrator edits a user's display name, email, phone, QQ, contact note, roles, or status
|
||||||
|
- **THEN** the frontend MUST submit a named API request, render success or failure feedback, and update the list from the persisted response
|
||||||
|
|
||||||
|
#### Scenario: Administrator removes or deactivates a user
|
||||||
|
- **WHEN** a platform administrator confirms a user removal or deactivation action
|
||||||
|
- **THEN** the platform MUST enforce the resource safety rule and the frontend MUST render the resulting removed, disabled, or rejected state without pretending a local write succeeded
|
||||||
|
|
||||||
|
#### Scenario: Non-admin reaches user management
|
||||||
|
- **WHEN** a user without `users.manage` reaches the user management route directly
|
||||||
|
- **THEN** the page MUST avoid rendering account maintenance controls and MUST return or explain the authorized workspace state
|
||||||
|
|
||||||
|
### Requirement: Server management supports metadata edit and safe removal
|
||||||
|
The server management workspace SHALL allow authorized users to create server instances, edit server metadata, manage server administrators, start and stop eligible instances, and archive or delete safe instances through platform-mediated APIs.
|
||||||
|
|
||||||
|
#### Scenario: Operator edits server metadata
|
||||||
|
- **WHEN** an authorized operator updates a server name, ownership-visible metadata, or other editable server fields
|
||||||
|
- **THEN** the frontend MUST submit a typed platform API request and render the persisted server instance response
|
||||||
|
|
||||||
|
#### Scenario: Operator archives or deletes a server
|
||||||
|
- **WHEN** an authorized operator confirms archive or delete for a server instance
|
||||||
|
- **THEN** the platform MUST reject unsafe states such as running instances unless the chosen operation is explicitly allowed, and the frontend MUST render the accepted or rejected result with diagnostic context
|
||||||
|
|
||||||
|
#### Scenario: Server detail manages administrators
|
||||||
|
- **WHEN** a server owner adds or removes server administrators from the server detail page
|
||||||
|
- **THEN** the frontend MUST use platform administrator membership APIs and refresh candidate and assigned member state after the operation
|
||||||
|
|
||||||
|
### Requirement: Plugin marketplace avoids production demo fallbacks
|
||||||
|
The plugin marketplace SHALL render platform marketplace data and state actions from platform APIs and SHALL NOT rely on `pluginCatalog` fallback data in production behavior.
|
||||||
|
|
||||||
|
#### Scenario: Marketplace API is unavailable
|
||||||
|
- **WHEN** the marketplace list or detail API request fails
|
||||||
|
- **THEN** the marketplace page MUST show an error or explicitly labeled development fixture state and MUST NOT present local catalog rows as persisted marketplace data
|
||||||
|
|
||||||
|
#### Scenario: Plugin state action is submitted
|
||||||
|
- **WHEN** an operator installs, enables, or disables a plugin
|
||||||
|
- **THEN** the frontend MUST call the platform marketplace state API, display the operation result, and update the selected plugin detail from the persisted response
|
||||||
|
|
||||||
|
#### Scenario: Plugin detail is refreshed
|
||||||
|
- **WHEN** an operator selects or refreshes a plugin detail
|
||||||
|
- **THEN** the frontend MUST prefer the platform detail API response and MUST render validation, permission, lifecycle, page, bridge, and AI purpose metadata without exposing secrets or run internals
|
||||||
|
|
||||||
|
### Requirement: AI provider management handles empty data and retirement safely
|
||||||
|
The AI provider management page SHALL handle empty API lists correctly and SHALL support create, update, enable/disable, test, model refresh, and deletion or retirement semantics without exposing raw AI key material.
|
||||||
|
|
||||||
|
#### Scenario: AI provider API returns zero providers
|
||||||
|
- **WHEN** the AI provider list API succeeds with zero providers
|
||||||
|
- **THEN** the page MUST render an empty state or creation form and MUST NOT keep seed providers visible
|
||||||
|
|
||||||
|
#### Scenario: Provider is saved
|
||||||
|
- **WHEN** an operator creates or updates an AI provider
|
||||||
|
- **THEN** the frontend MUST submit a named API request using secret references only and MUST render the redacted provider response
|
||||||
|
|
||||||
|
#### Scenario: Provider is retired or deleted
|
||||||
|
- **WHEN** an operator confirms provider deletion or retirement
|
||||||
|
- **THEN** the platform MUST enforce reference safety and the frontend MUST remove, disable, or mark the provider according to the persisted response
|
||||||
|
|
||||||
|
#### Scenario: Provider action fails
|
||||||
|
- **WHEN** provider save, status, test, model refresh, delete, or retire action fails
|
||||||
|
- **THEN** the frontend MUST display failure feedback and MUST NOT mutate local state as if the action succeeded
|
||||||
|
|
||||||
|
### Requirement: Management completion preserves security boundaries
|
||||||
|
The completed management workflows SHALL NOT expose raw AI keys, raw host paths, run credentials, direct socket addresses, or unrestricted plugin execution controls to `platform_web` or plugin pages.
|
||||||
|
|
||||||
|
#### Scenario: Page renders operational data
|
||||||
|
- **WHEN** any completed management page renders users, servers, plugins, AI providers, logs, audit events, artifacts, jobs, or run endpoints
|
||||||
|
- **THEN** the rendered data MUST omit raw AI keys, raw host paths, run credentials, and direct socket details
|
||||||
|
|
||||||
|
#### Scenario: Plugin control action is rendered
|
||||||
|
- **WHEN** plugin controls or bridge actions are rendered for a server
|
||||||
|
- **THEN** the controls MUST be derived from platform-approved plugin metadata and MUST dispatch through platform APIs rather than direct run or host access
|
||||||
|
|
||||||
|
### Requirement: Management completion is verified end to end
|
||||||
|
The change SHALL include automated tests, structure validation, strict OpenSpec validation, and browser walkthrough evidence for the completed management workflows.
|
||||||
|
|
||||||
|
#### Scenario: Verification commands run
|
||||||
|
- **WHEN** the implementation is complete
|
||||||
|
- **THEN** relevant backend tests, `cd platform_web && npm run typecheck && npm run test && npm run build`, `scripts/check-structure.sh`, and `openspec validate complete-platform-web-management-workflows --strict` MUST pass or have documented blockers
|
||||||
|
|
||||||
|
#### Scenario: Browser walkthrough covers edited pages
|
||||||
|
- **WHEN** frontend management workflows are claimed complete
|
||||||
|
- **THEN** a browser walkthrough MUST verify users, servers, plugin marketplace, AI providers, and related error/empty states at desktop and narrow widths
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
## 1. API and Safety Inventory
|
||||||
|
|
||||||
|
- [x] 1.1 Inventory current platform APIs for users, server instances, server administrators, plugin marketplace, AI providers, config, logs, run endpoints, and audit events.
|
||||||
|
- [x] 1.2 Decide and document resource-specific removal semantics for users, server instances, and AI providers: delete, disable, archive, or retire.
|
||||||
|
- [x] 1.3 Identify missing backend DTOs, validators, repository methods, service methods, and HTTP routes required by the frontend completion workflows.
|
||||||
|
- [x] 1.4 Confirm no planned request or response shape includes raw AI keys, raw host paths, run credentials, direct socket details, or unrestricted plugin execution fields.
|
||||||
|
|
||||||
|
## 2. Platform API Support
|
||||||
|
|
||||||
|
- [x] 2.1 Add or extend user management APIs for editing identity/contact fields, roles, status, and delete/deactivate behavior.
|
||||||
|
- [x] 2.2 Add or extend server instance APIs for metadata edits and safe archive/delete behavior while preserving lifecycle validation.
|
||||||
|
- [x] 2.3 Add or extend AI provider APIs for empty list correctness and delete/retire behavior with secret-reference-only validation.
|
||||||
|
- [x] 2.4 Add backend tests for accepted and rejected edit/delete/archive/retire workflows and stable JSON errors.
|
||||||
|
- [x] 2.5 Update platform route/API documentation for newly added management actions.
|
||||||
|
|
||||||
|
## 3. Frontend Contracts and Schemas
|
||||||
|
|
||||||
|
- [x] 3.1 Add frontend API DTO types and client methods for all new user, server, plugin, and AI provider management actions.
|
||||||
|
- [x] 3.2 Add shared frontend contracts for edit forms, removal confirmations, operation result state, and development-fixture state outside page components.
|
||||||
|
- [x] 3.3 Add or update frontend schemas for user edit, server metadata edit, server removal, AI provider save, and AI provider retirement requests.
|
||||||
|
- [x] 3.4 Remove unused static shell demo constants or isolate them as explicit test/development fixtures.
|
||||||
|
|
||||||
|
## 4. User Management Completion
|
||||||
|
|
||||||
|
- [x] 4.1 Replace silent `fallbackUsers` display with API-backed loading, empty, error, and explicitly labeled local-development states.
|
||||||
|
- [x] 4.2 Add existing-user edit controls for profile/contact fields, roles, and status using typed API requests.
|
||||||
|
- [x] 4.3 Add user delete/deactivate confirmation flow with persisted result feedback and rejection diagnostics.
|
||||||
|
- [x] 4.4 Add tests for user empty state, edit success, edit failure, status update, and delete/deactivate behavior.
|
||||||
|
|
||||||
|
## 5. Server Management Completion
|
||||||
|
|
||||||
|
- [x] 5.1 Add server list or detail controls for editable server metadata such as display name and allowed ownership-visible fields.
|
||||||
|
- [x] 5.2 Add safe server archive/delete flow with state-aware confirmation and platform rejection feedback.
|
||||||
|
- [x] 5.3 Ensure server administrator add/remove flows refresh assigned administrators and candidates after each operation.
|
||||||
|
- [x] 5.4 Replace server config fallback behavior with explicit API unavailable state or clearly labeled local-development fixture state.
|
||||||
|
- [x] 5.5 Add tests for server metadata edit, archive/delete rejection, administrator refresh, and config unavailable state.
|
||||||
|
|
||||||
|
## 6. Plugin Marketplace Completion
|
||||||
|
|
||||||
|
- [x] 6.1 Remove production reliance on `pluginCatalog` fallback data from marketplace list and detail rendering.
|
||||||
|
- [x] 6.2 Render marketplace API empty and error states with retry and diagnostic context.
|
||||||
|
- [x] 6.3 Ensure install, enable, and disable actions update list and detail state only from persisted platform responses.
|
||||||
|
- [x] 6.4 Add tests for marketplace API failure, empty list, detail refresh, disabled fixture actions, and state action feedback.
|
||||||
|
|
||||||
|
## 7. AI Provider Completion
|
||||||
|
|
||||||
|
- [x] 7.1 Fix zero-provider API responses so seed providers are not kept visible after a successful empty list.
|
||||||
|
- [x] 7.2 Remove optimistic local success for failed save, status, test, model refresh, and delete/retire actions.
|
||||||
|
- [x] 7.3 Add delete or retire action UI with confirmation, persisted response handling, and reference-safety rejection feedback.
|
||||||
|
- [x] 7.4 Add tests for empty provider list, create/update failure, status failure, model refresh failure, and delete/retire behavior.
|
||||||
|
|
||||||
|
## 8. Verification
|
||||||
|
|
||||||
|
- [x] 8.1 Run relevant backend tests from `platform/` and record evidence.
|
||||||
|
- [x] 8.2 Run `cd platform_web && npm run typecheck && npm run test && npm run build` and record evidence.
|
||||||
|
- [x] 8.3 Run a browser walkthrough covering users, servers, plugin marketplace, AI providers, and empty/error states at desktop and narrow widths.
|
||||||
|
- [x] 8.4 Run forbidden-fragment checks for raw AI keys, raw host paths, run credentials, and direct socket details in rendered management pages.
|
||||||
|
- [x] 8.5 Run `scripts/check-structure.sh` and record evidence.
|
||||||
|
- [x] 8.6 Run `openspec validate complete-platform-web-management-workflows --strict` and record evidence.
|
||||||
|
|
||||||
|
## 9. Management Layout Corrections
|
||||||
|
|
||||||
|
- [x] 9.1 Replace permanent inline create/edit/detail panes on AI provider, user, and plugin marketplace list pages with modal workflows while preserving full-width list surfaces.
|
||||||
|
- [x] 9.2 Document the ban on permanent right-side or inline split management panes in platform_web Markdown guidance.
|
||||||
|
- [x] 9.3 Verify AI provider create/edit, user create/edit, and plugin detail workflows in a browser after the modal conversion.
|
||||||
@@ -40,10 +40,18 @@ This change should stay inside platform_web interaction and visual polish. It mu
|
|||||||
|
|
||||||
Lifecycle actions, destructive confirmations, logs, diffs, config review, operation results, warnings, and AI recommendations must remain text-readable, traceable, and not color-only. Decorative theme effects must stay behind operational surfaces and respect reduced motion.
|
Lifecycle actions, destructive confirmations, logs, diffs, config review, operation results, warnings, and AI recommendations must remain text-readable, traceable, and not color-only. Decorative theme effects must stay behind operational surfaces and respect reduced motion.
|
||||||
|
|
||||||
5. Use browser walkthroughs for acceptance, not screenshots alone.
|
5. Demote low-value counters near management headers.
|
||||||
|
|
||||||
|
Tiny counts such as installed plugin count, user count, role count, pending review count, provider count, enabled count, and model count are useful as context but should not become primary framed KPI cards. Management pages should keep scarce vertical space for the actual work surface: search/filter bars, plugin catalog, user table, and provider table.
|
||||||
|
|
||||||
|
6. Use browser walkthroughs for acceptance, not screenshots alone.
|
||||||
|
|
||||||
Completion requires exercising the actual routes and workflows in a browser at representative desktop and mobile widths. Screenshots can help debugging, but accepted evidence should focus on route behavior, visible controls, responsive layout, and absence of overlap or sensitive/fallback content.
|
Completion requires exercising the actual routes and workflows in a browser at representative desktop and mobile widths. Screenshots can help debugging, but accepted evidence should focus on route behavior, visible controls, responsive layout, and absence of overlap or sensitive/fallback content.
|
||||||
|
|
||||||
|
7. Prefer human-operable flows over raw DTO forms.
|
||||||
|
|
||||||
|
Management pages should expose the same platform-backed actions, but the UI should bundle them into understandable operator flows: one status-change path per user, readable AI provider row actions, provider setup presets and secret-reference help, and maintenance triage entry points that connect failed jobs or stale endpoints to the next useful page. These additions remain presentation/workflow polish and do not add new authorization, run, plugin, or provider semantics.
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- Visual polish can drift into scope expansion -> Keep tasks limited to platform_web interaction and theme presentation; do not change platform/run/plugin semantics.
|
- Visual polish can drift into scope expansion -> Keep tasks limited to platform_web interaction and theme presentation; do not change platform/run/plugin semantics.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ The console now has real API-backed coverage and automated browser acceptance, b
|
|||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- Define accepted interaction/design criteria for the required first-party areas: 首页、服务器管理、插件市场、用户管理、AI 提供商管理.
|
- Define accepted interaction/design criteria for the required first-party areas: 首页、服务器管理、插件市场、用户管理、AI 提供商管理.
|
||||||
|
- Compress low-value management page summary counts into compact status context so primary list/table surfaces keep the scarce first-screen space.
|
||||||
- Define server detail workflow polish for lifecycle controls, logs, config, plugin controls, AI assistant, and operation history.
|
- Define server detail workflow polish for lifecycle controls, logs, config, plugin controls, AI assistant, and operation history.
|
||||||
- Require responsive desktop/mobile walkthrough coverage and no visible overlap, clipped text, unreadable panels, or inaccessible control states.
|
- Require responsive desktop/mobile walkthrough coverage and no visible overlap, clipped text, unreadable panels, or inaccessible control states.
|
||||||
- Require the polish to preserve the existing black mecha default theme, magical-girl alternate theme, translucent game-operations surfaces, grouped navigation, and shared theme primitives.
|
- Require the polish to preserve the existing black mecha default theme, magical-girl alternate theme, translucent game-operations surfaces, grouped navigation, and shared theme primitives.
|
||||||
|
|||||||
+4
@@ -23,6 +23,10 @@ The platform_web console SHALL provide polished, scannable, API-backed interacti
|
|||||||
- **WHEN** an operator opens AI 提供商管理
|
- **WHEN** an operator opens AI 提供商管理
|
||||||
- **THEN** the route MUST present provider identity, connection status, relay mode, model/default-model information, and redacted key references without exposing raw keys or making status dependent on color alone
|
- **THEN** the route MUST present provider identity, connection status, relay mode, model/default-model information, and redacted key references without exposing raw keys or making status dependent on color alone
|
||||||
|
|
||||||
|
#### Scenario: Management page counters stay secondary
|
||||||
|
- **WHEN** an operator opens 插件市场、用户管理, or AI 提供商管理
|
||||||
|
- **THEN** small summary counts such as installed plugins, bridge actions, validation failures, users, roles, pending reviews, providers, enabled providers, and models MUST render as compact contextual status instead of large framed KPI cards that displace the primary list, grid, table, search, or filter work surface
|
||||||
|
|
||||||
### Requirement: Server detail workflows are polished without direct run access
|
### Requirement: Server detail workflows are polished without direct run access
|
||||||
The platform_web server detail route SHALL provide polished workflow surfaces for lifecycle, logs, config, plugin controls, AI assistant, and operation history while preserving platform-mediated boundaries.
|
The platform_web server detail route SHALL provide polished workflow surfaces for lifecycle, logs, config, plugin controls, AI assistant, and operation history while preserving platform-mediated boundaries.
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,21 @@
|
|||||||
- [x] 6.4 If structural theme rules or shared style contracts change, update `platform_web/theme/README.md` and any relevant tests in the same change.
|
- [x] 6.4 If structural theme rules or shared style contracts change, update `platform_web/theme/README.md` and any relevant tests in the same change.
|
||||||
- [x] 6.5 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` and `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` after implementation evidence exists.
|
- [x] 6.5 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` and `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` after implementation evidence exists.
|
||||||
|
|
||||||
|
## 7. Management Header Density Correction
|
||||||
|
|
||||||
|
- [x] 7.1 Replace large framed management summary KPI cards in 插件市场、用户管理, and AI 提供商管理 with compact contextual status chips.
|
||||||
|
- [x] 7.2 Add regression tests proving those management headers render `page-summary-chip` instead of `metric-card`.
|
||||||
|
- [x] 7.3 Run focused frontend tests, `scripts/check-structure.sh`, and `openspec validate polish-platform-interaction-design --strict`; record evidence.
|
||||||
|
|
||||||
|
## 8. Human Workflow Polish Follow-up
|
||||||
|
|
||||||
|
- [x] 8.1 Replace duplicate 用户管理 status/deactivation buttons with one status selector flow and explicit confirmation when disabling access.
|
||||||
|
- [x] 8.2 Add user invitation, review, server-scope, and role-impact guidance without adding new backend authorization semantics.
|
||||||
|
- [x] 8.3 Replace AI provider icon-only row actions with readable action labels and a compact "more" menu for lower-frequency enable/retire actions.
|
||||||
|
- [x] 8.4 Add AI provider setup guidance: presets, secret-reference help, save-before validation, saved-configuration testing, and model discovery fill-in.
|
||||||
|
- [x] 8.5 Add 系统维护 triage entry points for endpoint heartbeat/capacity, failed jobs, and related server/log navigation links.
|
||||||
|
- [x] 8.6 Run focused frontend tests and typecheck; use the existing 5173 browser instance to spot-check the changed pages.
|
||||||
|
|
||||||
## Evidence
|
## Evidence
|
||||||
|
|
||||||
- `platform_web/pages/UsersPage.tsx`: added explicit loading, API fallback error, empty-state, and accessible row action labels for 用户管理.
|
- `platform_web/pages/UsersPage.tsx`: added explicit loading, API fallback error, empty-state, and accessible row action labels for 用户管理.
|
||||||
@@ -51,7 +66,14 @@
|
|||||||
- `platform_web/pages/PluginsPage.tsx`: polished plugin detail framing and action strip behavior.
|
- `platform_web/pages/PluginsPage.tsx`: polished plugin detail framing and action strip behavior.
|
||||||
- `platform_web/theme/base.css`: hardened shared action strips, server toolbars, catalog cards, plugin detail panels, result strips, responsive grids, and table/workspace min-width behavior.
|
- `platform_web/theme/base.css`: hardened shared action strips, server toolbars, catalog cards, plugin detail panels, result strips, responsive grids, and table/workspace min-width behavior.
|
||||||
- `platform_web/acceptance/browser-acceptance.mjs`: expanded browser acceptance to record desktop/mobile walkthroughs for black mecha and magical-girl themes, route marker checks, visible-layout checks, API-backed route proof, plugin controls, and operation-history proof.
|
- `platform_web/acceptance/browser-acceptance.mjs`: expanded browser acceptance to record desktop/mobile walkthroughs for black mecha and magical-girl themes, route marker checks, visible-layout checks, API-backed route proof, plugin controls, and operation-history proof.
|
||||||
|
- Header density correction evidence: `platform_web/components/PageFrame.tsx` and `platform_web/pages/AiProvidersPage.tsx` now render management summary counts as `page-summary-chip`; `platform_web/pages/PluginsPage.test.tsx`, `platform_web/pages/UsersPage.test.tsx`, and `platform_web/pages/AiProvidersPage.test.tsx` assert those headers no longer render `metric-card`.
|
||||||
|
- Focused frontend evidence: `cd platform_web && npm test -- PluginsPage.test.tsx UsersPage.test.tsx AiProvidersPage.test.tsx`, `cd platform_web && npm run typecheck`, and `cd platform_web && npm run build` passed for the header density correction.
|
||||||
- Browser evidence: `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh` passed; evidence file `/private/tmp/browser-local-debug-acceptance/browser-acceptance/browser-acceptance-evidence.json` records 7 required routes plus 4 walkthrough scenarios: desktop/mobile black mecha and desktop/mobile magical-girl.
|
- Browser evidence: `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh` passed; evidence file `/private/tmp/browser-local-debug-acceptance/browser-acceptance/browser-acceptance-evidence.json` records 7 required routes plus 4 walkthrough scenarios: desktop/mobile black mecha and desktop/mobile magical-girl.
|
||||||
- Frontend evidence: `cd platform_web && npm run typecheck`, `cd platform_web && npm test` (11 files / 49 tests), and `cd platform_web && npm run build` passed.
|
- Frontend evidence: `cd platform_web && npm run typecheck`, `cd platform_web && npm test` (11 files / 49 tests), and `cd platform_web && npm run build` passed.
|
||||||
- Structure evidence: `scripts/check-structure.sh` passed.
|
- Structure evidence: `scripts/check-structure.sh` passed.
|
||||||
- OpenSpec evidence: `openspec validate polish-platform-interaction-design --strict` passed.
|
- OpenSpec evidence: `openspec validate polish-platform-interaction-design --strict` passed.
|
||||||
|
- Human workflow polish evidence: `platform_web/pages/UsersPage.tsx` now uses one status selector/apply path, keeps disable confirmation, renames create flow to 邀请用户, and shows invite/review/server-scope/role-impact guidance.
|
||||||
|
- AI provider workflow evidence: `platform_web/pages/AiProvidersPage.tsx` now shows row actions as 测试 / 模型 / 编辑 / 更多, moves enable/retire into the more menu, adds provider presets, secret-reference help, 保存前检查, saved-configuration testing, and saved-model discovery fill-in.
|
||||||
|
- Maintenance triage evidence: `platform_web/pages/MaintenancePage.tsx` now loads endpoints, jobs, servers, and audit events to show node heartbeat/capacity detail, recent failed jobs, retry dispatch, related server links, and log-chain entry copy.
|
||||||
|
- Human workflow verification: `cd platform_web && npm test -- UsersPage.test.tsx AiProvidersPage.test.tsx ConsolePages.test.tsx`, `cd platform_web && npm run typecheck`, and `cd platform_web && npm run build` passed. `scripts/check-structure.sh` passed. `openspec validate polish-platform-interaction-design --strict` passed; only PostHog telemetry flushing failed due restricted network after validation succeeded.
|
||||||
|
- Browser spot-check evidence: existing `http://127.0.0.1:5173` was opened and logged in with the local test operator. `#/aiProviders` showed readable row actions and edit dialog guidance with no clipped target buttons and no `api.example.test`; `#/users` showed 邀请用户, 审核申请, 绑定服务器范围, 角色影响, 应用状态, no duplicate “停用用户” label, and no clipped target buttons; `#/maintenance` showed 系统维护, 节点详情, 最近失败任务, heartbeat/status context, and the follow-up commit adds visible 维护排障入口 / 查看相关服务器 / 查看日志链路 copy for empty-data states.
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow)
|
mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart)
|
mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop)
|
mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/run/update", h.serverRunUpdate)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/generate", h.serverClientManagerGenerate)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/download", h.serverClientManagerDownload)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/key/reset", h.serverClientManagerKeyReset)
|
||||||
|
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}/logs/live", h.serverLiveLogs)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
|
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
|
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig)
|
mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig)
|
||||||
@@ -893,26 +905,53 @@ func (h *coreHandlers) serverInstances(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// serverInstanceDetail godoc
|
// serverInstanceDetail godoc
|
||||||
// @Summary Get server instance
|
// @Summary Get, update, or archive server instance
|
||||||
// @Description Returns one server instance by ID.
|
// @Description Returns one server instance by ID, updates safe metadata, or archives it by marking the instance deleted after safety validation.
|
||||||
// @Tags server-instances
|
// @Tags server-instances
|
||||||
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Server instance ID"
|
// @Param id path string true "Server instance ID"
|
||||||
|
// @Param body body dto.ServerInstanceUpdateRequest false "Server metadata update request"
|
||||||
|
// @Success 204
|
||||||
// @Success 200 {object} dto.ServerInstanceResponse
|
// @Success 200 {object} dto.ServerInstanceResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
// @Failure 404 {object} dto.ErrorResponse
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
// @Router /api/v1/server-instances/{id} [get]
|
// @Router /api/v1/server-instances/{id} [get]
|
||||||
|
// @Router /api/v1/server-instances/{id} [put]
|
||||||
|
// @Router /api/v1/server-instances/{id} [delete]
|
||||||
func (h *coreHandlers) serverInstanceDetail(w http.ResponseWriter, r *http.Request) {
|
func (h *coreHandlers) serverInstanceDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
switch r.Method {
|
||||||
writeMethodNotAllowed(w, http.MethodGet)
|
case http.MethodGet:
|
||||||
return
|
|
||||||
}
|
|
||||||
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
||||||
|
case http.MethodPut:
|
||||||
|
request, err := decodeJSON[dto.ServerInstanceUpdateRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance, err := h.core.UpdateServerInstanceForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
||||||
|
case http.MethodDelete:
|
||||||
|
_, err := h.core.ArchiveServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet+", "+http.MethodPut+", "+http.MethodDelete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// platformMetrics godoc
|
// platformMetrics godoc
|
||||||
|
|||||||
@@ -211,7 +211,9 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
|||||||
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-config-api@example.test", Password: "secret-password"}).SessionID
|
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-config-api@example.test", Password: "secret-password"}).SessionID
|
||||||
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-config-api@example.test", Password: "secret-password"}).SessionID
|
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-config-api@example.test", Password: "secret-password"}).SessionID
|
||||||
|
|
||||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
pluginRequest := validGamePluginRequest()
|
||||||
|
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityConfigWrite, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite)
|
||||||
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||||
ID: "server-config-api",
|
ID: "server-config-api",
|
||||||
@@ -286,6 +288,101 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||||
|
router := newTestRouter()
|
||||||
|
adminSession := createAdminSession(t, router)
|
||||||
|
serverID := createRuntimeAPIFixtures(t, router, adminSession)
|
||||||
|
|
||||||
|
actions := getJSONWithAuth[dto.ServerRuntimeActionsResponse](t, router, "/api/v1/server-instances/"+serverID+"/runtime/actions", adminSession)
|
||||||
|
availability := map[string]bool{}
|
||||||
|
for _, action := range actions.Actions {
|
||||||
|
availability[action.Key] = action.Available
|
||||||
|
}
|
||||||
|
for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install", "historical-logs"} {
|
||||||
|
if !availability[key] {
|
||||||
|
t.Fatalf("expected action %q available in %+v", key, actions.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession)
|
||||||
|
if runDistribution.ArtifactID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" {
|
||||||
|
t.Fatalf("unexpected run distribution: %+v", runDistribution)
|
||||||
|
}
|
||||||
|
runDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
|
||||||
|
if runDownload.ArtifactID != runDistribution.ArtifactID || runDownload.DownloadURL == "" {
|
||||||
|
t.Fatalf("unexpected run download: %+v", runDownload)
|
||||||
|
}
|
||||||
|
updateRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/update", dto.RunUpdateRequest{ArtifactID: runDistribution.ArtifactID, Checksum: runDistribution.Checksum, IdempotencyKey: "api-run-update"}, adminSession)
|
||||||
|
assertStatus(t, updateRecorder, http.StatusAccepted)
|
||||||
|
update := decodeBody[dto.RunUpdateJobResponse](t, updateRecorder)
|
||||||
|
if update.JobID == "" || update.ArtifactID != runDistribution.ArtifactID || update.Status != string(domain.DistributionJobStatusQueued) {
|
||||||
|
t.Fatalf("unexpected run update job: %+v", update)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
|
||||||
|
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
|
||||||
|
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
|
||||||
|
}
|
||||||
|
clientDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
|
||||||
|
if clientDownload.ArtifactID != clientDistribution.ArtifactID {
|
||||||
|
t.Fatalf("unexpected client download: %+v", clientDownload)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession)
|
||||||
|
assertStatus(t, dependencyCheckRecorder, http.StatusAccepted)
|
||||||
|
dependencyCheck := decodeBody[dto.JobResponse](t, dependencyCheckRecorder)
|
||||||
|
if dependencyCheck.Capability != domain.JobCapabilityDependenciesCheck || dependencyCheck.TargetKey != "dependencies/java-runtime" {
|
||||||
|
t.Fatalf("unexpected dependency check job: %+v", dependencyCheck)
|
||||||
|
}
|
||||||
|
dependencyInstallRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "java-install", IdempotencyKey: "api-dependency-install"}, adminSession)
|
||||||
|
assertStatus(t, dependencyInstallRecorder, http.StatusAccepted)
|
||||||
|
dependencyInstall := decodeBody[dto.JobResponse](t, dependencyInstallRecorder)
|
||||||
|
if dependencyInstall.Capability != domain.JobCapabilityDependenciesInstall || dependencyInstall.TargetKey != "dependencies/install/java-install" {
|
||||||
|
t.Fatalf("unexpected dependency install job: %+v", dependencyInstall)
|
||||||
|
}
|
||||||
|
unsafeDependency := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "bash -c whoami", IdempotencyKey: "api-dependency-unsafe"}, adminSession)
|
||||||
|
assertErrorResponse(t, unsafeDependency, http.StatusBadRequest, errorCodeValidation)
|
||||||
|
|
||||||
|
backfillRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession)
|
||||||
|
assertStatus(t, backfillRecorder, http.StatusAccepted)
|
||||||
|
backfill := decodeBody[dto.JobResponse](t, backfillRecorder)
|
||||||
|
if backfill.Capability != domain.JobCapabilityLogsBackfill || backfill.ResultRef != "" || backfill.InputRef == "" {
|
||||||
|
t.Fatalf("unexpected log backfill job: %+v", backfill)
|
||||||
|
}
|
||||||
|
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
|
||||||
|
if liveLogs.Count != 1 || liveLogs.Items[0].StreamKey != "stdout" {
|
||||||
|
t.Fatalf("unexpected live logs: %+v", liveLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession)
|
||||||
|
if runReset.Generation != 2 || runReset.SecretRef == "" {
|
||||||
|
t.Fatalf("unexpected run key reset: %+v", runReset)
|
||||||
|
}
|
||||||
|
clientReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession)
|
||||||
|
if clientReset.Generation != 2 || clientReset.SecretRef == runReset.SecretRef {
|
||||||
|
t.Fatalf("unexpected client key reset: %+v", clientReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runDownload), mustJSON(t, clientDownload), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} {
|
||||||
|
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
|
||||||
|
if strings.Contains(body, forbidden) {
|
||||||
|
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audits := getJSONWithAuth[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?resourceId="+serverID, adminSession)
|
||||||
|
auditActions := map[string]bool{}
|
||||||
|
for _, audit := range audits.Items {
|
||||||
|
auditActions[audit.Action] = true
|
||||||
|
}
|
||||||
|
for _, action := range []string{"run.generate", "run.download", "run.update", "client-manager.build", "client-manager.download", "dependency.install", "logs.backfill", "runtime-key.reset"} {
|
||||||
|
if !auditActions[action] {
|
||||||
|
t.Fatalf("expected audit action %q in %+v", action, audits.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreAPIErrorResponses(t *testing.T) {
|
func TestCoreAPIErrorResponses(t *testing.T) {
|
||||||
router := newTestRouter()
|
router := newTestRouter()
|
||||||
adminSession := createAdminSession(t, router)
|
adminSession := createAdminSession(t, router)
|
||||||
@@ -562,6 +659,54 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
|
|||||||
assertErrorResponse(t, invalidStop, http.StatusBadRequest, errorCodeValidation)
|
assertErrorResponse(t, invalidStop, http.StatusBadRequest, errorCodeValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerInstanceManagementAPI(t *testing.T) {
|
||||||
|
router := newTestRouter()
|
||||||
|
adminSession := createAdminSession(t, router)
|
||||||
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||||
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||||
|
|
||||||
|
ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||||
|
ID: "server-management",
|
||||||
|
PluginID: "server.scum",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Name: "SCUM Ops",
|
||||||
|
State: domain.ServerInstanceStateReady,
|
||||||
|
}, adminSession)
|
||||||
|
|
||||||
|
newName := "SCUM Ops Renamed"
|
||||||
|
updated := putJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-management", dto.ServerInstanceUpdateRequest{Name: &newName}, adminSession)
|
||||||
|
if updated.Name != newName || updated.PluginID != ready.PluginID || updated.RunEndpointID != ready.RunEndpointID {
|
||||||
|
t.Fatalf("unexpected server update: %+v", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
running := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||||
|
ID: "server-running-archive",
|
||||||
|
PluginID: "server.scum",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Name: "SCUM Running Archive",
|
||||||
|
State: domain.ServerInstanceStateRunning,
|
||||||
|
}, adminSession)
|
||||||
|
unsafeArchive := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, "", adminSession)
|
||||||
|
assertErrorResponse(t, unsafeArchive, http.StatusBadRequest, errorCodeValidation)
|
||||||
|
|
||||||
|
archived := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", "", adminSession)
|
||||||
|
assertStatus(t, archived, http.StatusNoContent)
|
||||||
|
activeList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession)
|
||||||
|
for _, item := range activeList.Items {
|
||||||
|
if item.ID == "server-management" {
|
||||||
|
t.Fatalf("archived server should be hidden from normal list: %+v", activeList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deletedList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?state=deleted", adminSession)
|
||||||
|
if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted {
|
||||||
|
t.Fatalf("expected explicit deleted filter to return archived server, got %+v", deletedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
blank := ""
|
||||||
|
invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-archive", dto.ServerInstanceUpdateRequest{Name: &blank}, adminSession)
|
||||||
|
assertErrorResponse(t, invalidUpdate, http.StatusBadRequest, errorCodeValidation)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
||||||
router := newTestRouter()
|
router := newTestRouter()
|
||||||
adminSession := createAdminSession(t, router)
|
adminSession := createAdminSession(t, router)
|
||||||
@@ -1313,6 +1458,67 @@ func anyJSON(t *testing.T, value any) map[string]any {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession string) string {
|
||||||
|
t.Helper()
|
||||||
|
pluginRequest := validGamePluginRequest()
|
||||||
|
pluginRequest.ID = "server.runtime"
|
||||||
|
pluginRequest.Name = "Runtime Test Plugin"
|
||||||
|
pluginRequest.ServerType = "runtime-test"
|
||||||
|
pluginRequest.SupportedOS = []string{"linux", "windows"}
|
||||||
|
pluginRequest.RequiredRunCapabilities = []string{
|
||||||
|
"process.install",
|
||||||
|
"process.start",
|
||||||
|
"process.stop",
|
||||||
|
"logs.read",
|
||||||
|
domain.JobCapabilityRunSelfUpdate,
|
||||||
|
domain.JobCapabilityDependenciesCheck,
|
||||||
|
domain.JobCapabilityDependenciesInstall,
|
||||||
|
domain.JobCapabilityLogsBackfill,
|
||||||
|
}
|
||||||
|
pluginRequest.DeclaredPermissions = []string{
|
||||||
|
"server.read",
|
||||||
|
"server.logs.read",
|
||||||
|
"server.run.distribution",
|
||||||
|
"server.client-manager.manage",
|
||||||
|
"server.dependencies.manage",
|
||||||
|
"server.artifacts.read",
|
||||||
|
}
|
||||||
|
pluginRequest.BridgeActions = []string{
|
||||||
|
string(domain.PluginBridgeActionRunDistribution),
|
||||||
|
string(domain.PluginBridgeActionClientManager),
|
||||||
|
string(domain.PluginBridgeActionDependenciesRequest),
|
||||||
|
string(domain.PluginBridgeActionLogsBackfillRequest),
|
||||||
|
}
|
||||||
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||||
|
|
||||||
|
endpoint := validRunEndpointRequest()
|
||||||
|
endpoint.ID = "run-runtime"
|
||||||
|
endpoint.Capabilities = append(endpoint.Capabilities,
|
||||||
|
domain.JobCapabilityRunSelfUpdate,
|
||||||
|
domain.JobCapabilityDependenciesCheck,
|
||||||
|
domain.JobCapabilityDependenciesInstall,
|
||||||
|
domain.JobCapabilityLogsBackfill,
|
||||||
|
)
|
||||||
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
|
||||||
|
|
||||||
|
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||||
|
ID: "server-runtime-api",
|
||||||
|
PluginID: "server.runtime",
|
||||||
|
RunEndpointID: "run-runtime",
|
||||||
|
Name: "Runtime API Server",
|
||||||
|
State: domain.ServerInstanceStateReady,
|
||||||
|
}, adminSession)
|
||||||
|
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||||
|
ID: "log-runtime-api",
|
||||||
|
ServerInstanceID: server.ID,
|
||||||
|
Source: domain.LogStreamSourceProcess,
|
||||||
|
StreamKey: "stdout",
|
||||||
|
StorageBackend: domain.LogStorageBackendLocalSegments,
|
||||||
|
RetentionPolicy: "default",
|
||||||
|
})
|
||||||
|
return server.ID
|
||||||
|
}
|
||||||
|
|
||||||
func validAIProviderRequest() dto.AIProviderCreateRequest {
|
func validAIProviderRequest() dto.AIProviderCreateRequest {
|
||||||
return dto.AIProviderCreateRequest{
|
return dto.AIProviderCreateRequest{
|
||||||
ID: "ai.openai",
|
ID: "ai.openai",
|
||||||
|
|||||||
+21
-2
@@ -13,7 +13,8 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
|||||||
| Game plugins | `GET /api/v1/game-plugins`, `POST /api/v1/game-plugins` | `GET /api/v1/game-plugins/{id}` | `GamePluginCreateRequest`, `GamePluginResponse`, `GamePluginListResponse` |
|
| Game plugins | `GET /api/v1/game-plugins`, `POST /api/v1/game-plugins` | `GET /api/v1/game-plugins/{id}` | `GamePluginCreateRequest`, `GamePluginResponse`, `GamePluginListResponse` |
|
||||||
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
|
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
|
||||||
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
|
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
|
||||||
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
|
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
|
||||||
|
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyJobRequest`, `LogBackfillRequest` |
|
||||||
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
||||||
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
|
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
|
||||||
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
||||||
@@ -31,6 +32,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
|||||||
- `GET /api/v1/game-plugins?serverType=scum&status=installed`
|
- `GET /api/v1/game-plugins?serverType=scum&status=installed`
|
||||||
- `GET /api/v1/plugin-marketplace/plugins?serverType=scum&status=installed&capability=logs.read&keyword=scum`
|
- `GET /api/v1/plugin-marketplace/plugins?serverType=scum&status=installed&capability=logs.read&keyword=scum`
|
||||||
- `GET /api/v1/server-instances?pluginId=server.scum&runEndpointId=run-local&state=draft`
|
- `GET /api/v1/server-instances?pluginId=server.scum&runEndpointId=run-local&state=draft`
|
||||||
|
- `GET /api/v1/server-instances?state=deleted`
|
||||||
- `GET /api/v1/metrics/server-instances`
|
- `GET /api/v1/metrics/server-instances`
|
||||||
- `GET /api/v1/run/endpoints?status=online`
|
- `GET /api/v1/run/endpoints?status=online`
|
||||||
- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued`
|
- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued`
|
||||||
@@ -120,6 +122,23 @@ Artifact bridge execution returns safe metadata and platform content routes only
|
|||||||
|
|
||||||
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
|
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
|
||||||
|
|
||||||
|
## Implemented Runtime Distribution And Client Manager Actions
|
||||||
|
|
||||||
|
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
|
||||||
|
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, and redacted secret ref only.
|
||||||
|
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
|
||||||
|
- `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`.
|
||||||
|
- `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`.
|
||||||
|
- `POST /api/v1/server-instances/{id}/client-managers/generate`: accepts `ClientManagerBuildRequest`, validates the plugin-declared client-manager profile and target platform, injects a distinct current client-manager key into the package config, publishes a downloadable artifact, and returns `ClientManagerDistributionResponse`.
|
||||||
|
- `POST /api/v1/server-instances/{id}/client-managers/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`.
|
||||||
|
- `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`.
|
||||||
|
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||||
|
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and queues `dependencies.install` only for typed plugin-declared plans.
|
||||||
|
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
|
||||||
|
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
|
||||||
|
|
||||||
|
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and run endpoint capability support for run-side jobs. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||||
|
|
||||||
## Implemented Run Control Actions
|
## Implemented Run Control Actions
|
||||||
|
|
||||||
- `POST /api/v1/run/control/hello`: accept `RunControlHelloRequest`, create or update run endpoint metadata, and return `RunControlHelloResponse` with a platform-issued session token.
|
- `POST /api/v1/run/control/hello`: accept `RunControlHelloRequest`, create or update run endpoint metadata, and return `RunControlHelloResponse` with a platform-issued session token.
|
||||||
@@ -191,7 +210,7 @@ These route groups remain documented future work beyond the currently implemente
|
|||||||
- Browser artifact upload, external artifact storage backends, presigned URLs, and production throttling policies.
|
- Browser artifact upload, external artifact storage backends, presigned URLs, and production throttling policies.
|
||||||
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
|
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
|
||||||
- Live AI provider connectivity tests and remote model discovery.
|
- Live AI provider connectivity tests and remote model discovery.
|
||||||
- Server restart/update/delete routes.
|
- Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions.
|
||||||
|
|
||||||
## Core Service Boundary
|
## Core Service Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverRuntimeActions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions, err := h.core.GetServerRuntimeActionsForSession(bearerToken(r), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.ServerRuntimeActionsFromDomain(actions))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverRunGenerate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.RunDistributionGenerateRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
distribution, err := h.core.GenerateRunDistributionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, dto.RunDistributionFromDomain(distribution))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverRunDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reference, err := h.core.OpenLatestRunDistributionDownloadForSession(bearerToken(r), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverRunKeyReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := h.core.ResetComponentKeyForSession(bearerToken(r), domain.ComponentKeyResetRequest{ServerInstanceID: r.PathValue("id"), ComponentKind: domain.DistributionComponentRun})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.ComponentKeyFromDomain(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverRunUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.RunUpdateRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, err := h.core.PushRunUpdateForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.RunUpdateJobFromDomain(job))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverClientManagerGenerate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.ClientManagerBuildRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
distribution, err := h.core.GenerateClientManagerDistributionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, dto.ClientManagerDistributionFromDomain(distribution))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverClientManagerDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.ClientManagerDownloadRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reference, err := h.core.OpenLatestClientManagerDistributionDownloadForSession(bearerToken(r), r.PathValue("id"), request.ProfileKey)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverClientManagerKeyReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.ComponentKeyResetRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reset := request.ToDomain(r.PathValue("id"))
|
||||||
|
reset.ComponentKind = domain.DistributionComponentClientManager
|
||||||
|
key, err := h.core.ResetComponentKeyForSession(bearerToken(r), reset)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.ComponentKeyFromDomain(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverDependenciesCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.DependencyJobRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, err := h.core.QueueDependencyJobForSession(bearerToken(r), request.ToDomain(r.PathValue("id"), false))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.JobFromDomain(job))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverDependenciesInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.DependencyJobRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, err := h.core.QueueDependencyJobForSession(bearerToken(r), request.ToDomain(r.PathValue("id"), true))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.JobFromDomain(job))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverLiveLogs(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
|
||||||
|
}
|
||||||
|
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.LogStreamListFromDomain(streams))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) serverLogsBackfill(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := decodeJSON[dto.LogBackfillRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, err := h.core.QueueLogBackfillForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, dto.JobFromDomain(job))
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ type RunCapabilityReport struct {
|
|||||||
type RunControlHello struct {
|
type RunControlHello struct {
|
||||||
RegistrationToken string
|
RegistrationToken string
|
||||||
RunEndpointID string
|
RunEndpointID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ComponentKind DistributionComponentKind
|
||||||
|
ComponentKey string
|
||||||
|
KeyGeneration int
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Version string
|
Version string
|
||||||
Status RunEndpointStatus
|
Status RunEndpointStatus
|
||||||
|
|||||||
@@ -104,6 +104,56 @@ const (
|
|||||||
ArtifactStateFailed ArtifactState = "failed"
|
ArtifactStateFailed ArtifactState = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type DistributionComponentKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DistributionComponentRun DistributionComponentKind = "run"
|
||||||
|
DistributionComponentClientManager DistributionComponentKind = "client-manager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ComponentKeyStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ComponentKeyStatusActive ComponentKeyStatus = "active"
|
||||||
|
ComponentKeyStatusRevoked ComponentKeyStatus = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DistributionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DistributionStatusAvailable DistributionStatus = "available"
|
||||||
|
DistributionStatusRevoked DistributionStatus = "revoked"
|
||||||
|
DistributionStatusBuilding DistributionStatus = "building"
|
||||||
|
DistributionStatusFailed DistributionStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RuntimeBindingStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RuntimeBindingStatusComplete RuntimeBindingStatus = "complete"
|
||||||
|
RuntimeBindingStatusIncomplete RuntimeBindingStatus = "incomplete"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DependencyState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DependencyStateUnknown DependencyState = "unknown"
|
||||||
|
DependencyStatePresent DependencyState = "present"
|
||||||
|
DependencyStateMissing DependencyState = "missing"
|
||||||
|
DependencyStateInstalling DependencyState = "installing"
|
||||||
|
DependencyStateFailed DependencyState = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DistributionJobStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DistributionJobStatusQueued DistributionJobStatus = "queued"
|
||||||
|
DistributionJobStatusRunning DistributionJobStatus = "running"
|
||||||
|
DistributionJobStatusSucceeded DistributionJobStatus = "succeeded"
|
||||||
|
DistributionJobStatusFailed DistributionJobStatus = "failed"
|
||||||
|
DistributionJobStatusDenied DistributionJobStatus = "denied"
|
||||||
|
)
|
||||||
|
|
||||||
type LogStreamSource string
|
type LogStreamSource string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -213,6 +263,7 @@ type PluginPermissions struct {
|
|||||||
Files bool
|
Files bool
|
||||||
Jobs bool
|
Jobs bool
|
||||||
Artifacts bool
|
Artifacts bool
|
||||||
|
RemoteAccess bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginLifecycleActions struct {
|
type PluginLifecycleActions struct {
|
||||||
@@ -246,6 +297,14 @@ type GamePluginManifestAI struct {
|
|||||||
Purposes []string
|
Purposes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GamePluginRemoteAccess struct {
|
||||||
|
Methods []string
|
||||||
|
RunCapabilities []string
|
||||||
|
DatabaseEngines []string
|
||||||
|
RCON bool
|
||||||
|
LogTransfer bool
|
||||||
|
}
|
||||||
|
|
||||||
type GamePluginManifest struct {
|
type GamePluginManifest struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
@@ -260,6 +319,7 @@ type GamePluginManifest struct {
|
|||||||
Actions PluginLifecycleActions
|
Actions PluginLifecycleActions
|
||||||
Pages []GamePluginPage
|
Pages []GamePluginPage
|
||||||
AI GamePluginManifestAI
|
AI GamePluginManifestAI
|
||||||
|
RemoteAccess GamePluginRemoteAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginManifestRegistration struct {
|
type GamePluginManifestRegistration struct {
|
||||||
@@ -285,6 +345,7 @@ type GamePlugin struct {
|
|||||||
Pages []GamePluginPage
|
Pages []GamePluginPage
|
||||||
Tags []string
|
Tags []string
|
||||||
AIPurposes []string
|
AIPurposes []string
|
||||||
|
RemoteAccess GamePluginRemoteAccess
|
||||||
ValidationViolations []string
|
ValidationViolations []string
|
||||||
Status GamePluginStatus
|
Status GamePluginStatus
|
||||||
}
|
}
|
||||||
@@ -307,6 +368,7 @@ type PluginMarketplacePlugin struct {
|
|||||||
Pages []GamePluginPage
|
Pages []GamePluginPage
|
||||||
Tags []string
|
Tags []string
|
||||||
AIPurposes []string
|
AIPurposes []string
|
||||||
|
RemoteAccess GamePluginRemoteAccess
|
||||||
ValidationViolations []string
|
ValidationViolations []string
|
||||||
Status GamePluginStatus
|
Status GamePluginStatus
|
||||||
Source string
|
Source string
|
||||||
@@ -320,6 +382,11 @@ const (
|
|||||||
PluginBridgeActionLogsQuery PluginBridgeAction = "logs.query"
|
PluginBridgeActionLogsQuery PluginBridgeAction = "logs.query"
|
||||||
PluginBridgeActionArtifactsOpen PluginBridgeAction = "artifacts.open"
|
PluginBridgeActionArtifactsOpen PluginBridgeAction = "artifacts.open"
|
||||||
PluginBridgeActionFilesRequest PluginBridgeAction = "files.request"
|
PluginBridgeActionFilesRequest PluginBridgeAction = "files.request"
|
||||||
|
PluginBridgeActionRemoteAccessRequest PluginBridgeAction = "remote.access.request"
|
||||||
|
PluginBridgeActionRunDistribution PluginBridgeAction = "run.distribution.request"
|
||||||
|
PluginBridgeActionDependenciesRequest PluginBridgeAction = "dependencies.request"
|
||||||
|
PluginBridgeActionLogsBackfillRequest PluginBridgeAction = "logs.backfill.request"
|
||||||
|
PluginBridgeActionClientManager PluginBridgeAction = "client-manager.request"
|
||||||
PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke"
|
PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -383,6 +450,10 @@ type ServerInstance struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServerInstanceUpdate struct {
|
||||||
|
Name *string
|
||||||
|
}
|
||||||
|
|
||||||
type PlatformResourceUsage struct {
|
type PlatformResourceUsage struct {
|
||||||
CPUPercent float64
|
CPUPercent float64
|
||||||
MemoryPercent float64
|
MemoryPercent float64
|
||||||
@@ -496,6 +567,22 @@ const (
|
|||||||
JobCapabilityConfigWrite = "config.write"
|
JobCapabilityConfigWrite = "config.write"
|
||||||
JobCapabilityFilesRead = "files.read"
|
JobCapabilityFilesRead = "files.read"
|
||||||
JobCapabilityFilesWrite = "files.write"
|
JobCapabilityFilesWrite = "files.write"
|
||||||
|
JobCapabilityRemoteFTPRead = "remote.ftp.read"
|
||||||
|
JobCapabilityRemoteFTPWrite = "remote.ftp.write"
|
||||||
|
JobCapabilityRemoteRsyncRead = "remote.rsync.read"
|
||||||
|
JobCapabilityRemoteRsyncWrite = "remote.rsync.write"
|
||||||
|
JobCapabilityRemoteRunFilesRead = "remote.run.files.read"
|
||||||
|
JobCapabilityRemoteRunFilesWrite = "remote.run.files.write"
|
||||||
|
JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
|
||||||
|
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
|
||||||
|
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
|
||||||
|
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||||
|
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||||
|
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||||
|
JobCapabilityRunSelfUpdate = "run.self-update"
|
||||||
|
JobCapabilityDependenciesCheck = "dependencies.check"
|
||||||
|
JobCapabilityDependenciesInstall = "dependencies.install"
|
||||||
|
JobCapabilityLogsBackfill = "logs.backfill"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RunEndpoint struct {
|
type RunEndpoint struct {
|
||||||
@@ -539,6 +626,197 @@ type Artifact struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeBinding struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
Mode string
|
||||||
|
Bindings map[string]string
|
||||||
|
MissingKeys []string
|
||||||
|
Status RuntimeBindingStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type EncryptedComponentKey struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
ComponentKind DistributionComponentKind
|
||||||
|
ComponentKey string
|
||||||
|
EncryptedKey string
|
||||||
|
KeyHash string
|
||||||
|
Fingerprint string
|
||||||
|
SecretRef string
|
||||||
|
Generation int
|
||||||
|
Status ComponentKeyStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
ResetAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunDistribution struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
RunEndpointID string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
PackageFormat string
|
||||||
|
ArtifactID string
|
||||||
|
Checksum string
|
||||||
|
KeyGeneration int
|
||||||
|
SecretRef string
|
||||||
|
Status DistributionStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerDistribution struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
RepositoryURL string
|
||||||
|
SourceRevision string
|
||||||
|
BuildJobID string
|
||||||
|
ArtifactID string
|
||||||
|
Checksum string
|
||||||
|
KeyGeneration int
|
||||||
|
SecretRef string
|
||||||
|
Status DistributionStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DependencyStatus struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProbeKey string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
State DependencyState
|
||||||
|
Required bool
|
||||||
|
InstallPlanKey string
|
||||||
|
Message string
|
||||||
|
CheckedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerBuildJob struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ProfileKey string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
RepositoryURL string
|
||||||
|
SourceRevision string
|
||||||
|
ArtifactID string
|
||||||
|
Checksum string
|
||||||
|
KeyGeneration int
|
||||||
|
LogsRef string
|
||||||
|
Status DistributionJobStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunUpdateJob struct {
|
||||||
|
ID string
|
||||||
|
ServerInstanceID string
|
||||||
|
RunEndpointID string
|
||||||
|
ArtifactID string
|
||||||
|
Checksum string
|
||||||
|
JobID string
|
||||||
|
IdempotencyKey string
|
||||||
|
Status DistributionJobStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunDistributionGenerateRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerBuildRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ProfileKey string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
RepositoryURL string
|
||||||
|
SourceRevision string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentKeyResetRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ComponentKind DistributionComponentKind
|
||||||
|
ComponentKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentAuthenticationRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ComponentKind DistributionComponentKind
|
||||||
|
ComponentKey string
|
||||||
|
Generation int
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentAuthenticationResult struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ComponentKind DistributionComponentKind
|
||||||
|
ComponentKey string
|
||||||
|
Generation int
|
||||||
|
Allowed bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerRuntimeAction struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Available bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerRuntimeActions struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
RunEndpointID string
|
||||||
|
RunStatus RunEndpointStatus
|
||||||
|
Actions []ServerRuntimeAction
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunUpdateRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ArtifactID string
|
||||||
|
Checksum string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DependencyJobRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ProbeKey string
|
||||||
|
InstallPlanKey string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
IdempotencyKey string
|
||||||
|
Install bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogBackfillRequest struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
SourceKey string
|
||||||
|
CheckpointRef string
|
||||||
|
IdempotencyKey string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
type LogStream struct {
|
type LogStream struct {
|
||||||
ID string
|
ID string
|
||||||
ServerInstanceID string
|
ServerInstanceID string
|
||||||
@@ -606,6 +884,51 @@ type ArtifactFilter struct {
|
|||||||
State ArtifactState
|
State ArtifactState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeBindingFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ProfileKey string
|
||||||
|
Status RuntimeBindingStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type EncryptedComponentKeyFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ComponentKind DistributionComponentKind
|
||||||
|
ComponentKey string
|
||||||
|
Status ComponentKeyStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunDistributionFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
Status DistributionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerDistributionFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ProfileKey string
|
||||||
|
TargetOS string
|
||||||
|
TargetArch string
|
||||||
|
Status DistributionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type DependencyStatusFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ProbeKey string
|
||||||
|
State DependencyState
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerBuildJobFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
ProfileKey string
|
||||||
|
Status DistributionJobStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunUpdateJobFilter struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
Status DistributionJobStatus
|
||||||
|
}
|
||||||
|
|
||||||
type LogStreamFilter struct {
|
type LogStreamFilter struct {
|
||||||
ServerInstanceID string
|
ServerInstanceID string
|
||||||
StreamKey string
|
StreamKey string
|
||||||
@@ -666,6 +989,7 @@ 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.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||||
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
||||||
return plugin
|
return plugin
|
||||||
}
|
}
|
||||||
@@ -678,6 +1002,7 @@ 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.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||||
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
||||||
return plugin
|
return plugin
|
||||||
}
|
}
|
||||||
@@ -706,9 +1031,17 @@ 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.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
|
||||||
return manifest
|
return manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CopyGamePluginRemoteAccess(remote GamePluginRemoteAccess) GamePluginRemoteAccess {
|
||||||
|
remote.Methods = CopyStringSlice(remote.Methods)
|
||||||
|
remote.RunCapabilities = CopyStringSlice(remote.RunCapabilities)
|
||||||
|
remote.DatabaseEngines = CopyStringSlice(remote.DatabaseEngines)
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
|
||||||
func CopyGamePluginPageSlice(pages []GamePluginPage) []GamePluginPage {
|
func CopyGamePluginPageSlice(pages []GamePluginPage) []GamePluginPage {
|
||||||
if pages == nil {
|
if pages == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -807,6 +1140,86 @@ func CopyArtifact(artifact Artifact) Artifact {
|
|||||||
return artifact
|
return artifact
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CopyRuntimeBinding(binding RuntimeBinding) RuntimeBinding {
|
||||||
|
binding.Bindings = CopyStringMap(binding.Bindings)
|
||||||
|
binding.MissingKeys = CopyStringSlice(binding.MissingKeys)
|
||||||
|
return binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyEncryptedComponentKey(key EncryptedComponentKey) EncryptedComponentKey {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyRunDistribution(distribution RunDistribution) RunDistribution {
|
||||||
|
return distribution
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyClientManagerDistribution(distribution ClientManagerDistribution) ClientManagerDistribution {
|
||||||
|
return distribution
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyDependencyStatus(status DependencyStatus) DependencyStatus {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyClientManagerBuildJob(job ClientManagerBuildJob) ClientManagerBuildJob {
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyRunUpdateJob(job RunUpdateJob) RunUpdateJob {
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyRunDistributionGenerateRequest(request RunDistributionGenerateRequest) RunDistributionGenerateRequest {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyClientManagerBuildRequest(request ClientManagerBuildRequest) ClientManagerBuildRequest {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyComponentKeyResetRequest(request ComponentKeyResetRequest) ComponentKeyResetRequest {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyComponentAuthenticationRequest(request ComponentAuthenticationRequest) ComponentAuthenticationRequest {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyComponentAuthenticationResult(result ComponentAuthenticationResult) ComponentAuthenticationResult {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyServerRuntimeAction(action ServerRuntimeAction) ServerRuntimeAction {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyServerRuntimeActions(actions ServerRuntimeActions) ServerRuntimeActions {
|
||||||
|
actions.Actions = CopyServerRuntimeActionSlice(actions.Actions)
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyServerRuntimeActionSlice(actions []ServerRuntimeAction) []ServerRuntimeAction {
|
||||||
|
if actions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]ServerRuntimeAction, len(actions))
|
||||||
|
copy(out, actions)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyRunUpdateRequest(request RunUpdateRequest) RunUpdateRequest {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyDependencyJobRequest(request DependencyJobRequest) DependencyJobRequest {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyLogBackfillRequest(request LogBackfillRequest) LogBackfillRequest {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
func CopyLogStream(stream LogStream) LogStream {
|
func CopyLogStream(stream LogStream) LogStream {
|
||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,16 +48,21 @@ This file defines the first platform resource contracts. Concrete Go domain stru
|
|||||||
- `createFormSchemaRef`: create form schema reference.
|
- `createFormSchemaRef`: create form schema reference.
|
||||||
- `requiredRunCapabilities`: run capabilities required by this plugin.
|
- `requiredRunCapabilities`: run capabilities required by this plugin.
|
||||||
- `declaredPermissions`: scoped manifest permission keys used by plugin bridge and marketplace views.
|
- `declaredPermissions`: scoped manifest permission keys used by plugin bridge and marketplace views.
|
||||||
- `permissions`: aggregate platform ability declarations for AI, logs, files, jobs, and artifacts.
|
- `permissions`: aggregate platform ability declarations for AI, logs, files, jobs, artifacts, and remote access.
|
||||||
- `lifecycleActions`: manifest action contract references for install/start/stop and optional restart/status.
|
- `lifecycleActions`: manifest action contract references for install/start/stop and optional restart/status.
|
||||||
- `pages`: plugin-local page metadata with scoped permission requirements.
|
- `pages`: plugin-local page metadata with scoped permission requirements.
|
||||||
- `tags`: bounded catalog tags.
|
- `tags`: bounded catalog tags.
|
||||||
- `aiPurposes`: platform-mediated AI purposes such as config suggestions or log diagnosis.
|
- `aiPurposes`: platform-mediated AI purposes such as config suggestions or log diagnosis.
|
||||||
|
- `remoteAccess`: plugin-declared remote access methods (`ftp`, `rsync`, `run`), run capabilities, database engines, RCON, and log transfer flags.
|
||||||
- `validationViolations`: safe validation findings for invalid plugin records.
|
- `validationViolations`: safe validation findings for invalid plugin records.
|
||||||
- `status`: `installed`, `disabled`, `invalid`, or `updating`.
|
- `status`: `installed`, `disabled`, `invalid`, or `updating`.
|
||||||
|
|
||||||
Manifest registration uses `GamePluginManifestRegistrationRequest` at `POST /api/v1/game-plugins/register-manifest`. Platform validation repeats plugin workspace safety checks and rejects raw host paths, direct run sockets, raw credentials, and raw AI/provider keys before metadata reaches the registry.
|
Manifest registration uses `GamePluginManifestRegistrationRequest` at `POST /api/v1/game-plugins/register-manifest`. Platform validation repeats plugin workspace safety checks and rejects raw host paths, direct run sockets, raw credentials, and raw AI/provider keys before metadata reaches the registry.
|
||||||
|
|
||||||
|
Remote access jobs are enabled only when both the selected run endpoint reports the capability and the server instance's installed plugin declares it. Plugin pages must use `remote.access.request` with `server.remote.access`; platform rejects undeclared database, RCON, log transfer, or remote file capabilities before creating jobs.
|
||||||
|
|
||||||
|
Runtime profile and distribution permissions are declared by plugins, then gated again by platform routes and services. `server.run.distribution` enables run package generation/download/reset/update operations, `server.dependencies.manage` enables dependency check/install jobs, and `server.client-manager.manage` enables plugin-declared companion client-manager generation/download/reset operations. Plugin metadata stores only declarations and safe refs; raw run/client-manager keys and transport credentials are stored through platform secret resources, never in plugin records.
|
||||||
|
|
||||||
## ServerInstance
|
## ServerInstance
|
||||||
|
|
||||||
- `id`: server instance ID.
|
- `id`: server instance ID.
|
||||||
@@ -80,6 +85,46 @@ Manifest registration uses `GamePluginManifestRegistrationRequest` at `POST /api
|
|||||||
- `capacity`: current queue and resource summary.
|
- `capacity`: current queue and resource summary.
|
||||||
- `lastHeartbeatAt`: last control heartbeat time.
|
- `lastHeartbeatAt`: last control heartbeat time.
|
||||||
|
|
||||||
|
Run control hello can include server/component identity from a generated package config. When `serverInstanceId`, `pluginId`, `componentKind`, `componentKey`, and `keyGeneration` are present, platform authenticates the provided key against the current encrypted component key before issuing a session token. Stale generations after reset are rejected without returning raw key material.
|
||||||
|
|
||||||
|
## RuntimeBinding
|
||||||
|
|
||||||
|
- `id`: runtime binding ID.
|
||||||
|
- `serverInstanceId`: server instance using the binding.
|
||||||
|
- `pluginId`: installed plugin that declared the logical runtime profile.
|
||||||
|
- `profileKey`: declared lifecycle/runtime profile key.
|
||||||
|
- `mode`: runtime mode such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`.
|
||||||
|
- `bindings`: logical binding keys to operator-provided settings.
|
||||||
|
- `missingKeys`: logical keys that must be completed before dependent actions are available.
|
||||||
|
- `status`: `complete`, `incomplete`, or `invalid`.
|
||||||
|
|
||||||
|
Bindings are used for action gating and run-side profile resolution. API responses and logs must use logical keys and safe reasons only; they must not expose raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, or component auth keys.
|
||||||
|
|
||||||
|
## Runtime Component Keys And Distributions
|
||||||
|
|
||||||
|
- `EncryptedComponentKey`: stores exactly one active encrypted key per server/component plus hash, fingerprint, redacted secret ref, generation, status, and reset time.
|
||||||
|
- `RunDistribution`: records a generated run package for one server, target OS/architecture, package format, artifact ID, checksum, key generation, secret ref, and status.
|
||||||
|
- `ClientManagerDistribution`: records a generated plugin-declared client-manager package with profile key, repository/source revision metadata, build job ID, artifact ID, checksum, key generation, secret ref, and status.
|
||||||
|
- `ClientManagerBuildJob`: records source checkout/build status, target platform, artifact ID, checksum, redacted build log ref, key generation, and status.
|
||||||
|
- `RunUpdateJob`: records platform-created run self-update orchestration with server, run endpoint, artifact ID, checksum, job ID, idempotency key, and status.
|
||||||
|
|
||||||
|
Run and client-manager keys are isolated singleton credentials. Reset replaces the encrypted database value, increments generation, marks older distributions revoked, and requires regenerating and redeploying that component. API DTOs may expose key generation, fingerprint, status, artifact ID, checksum, job ID, and `secret://runtime-keys/.../current` refs, but never the raw key.
|
||||||
|
|
||||||
|
## DependencyStatus
|
||||||
|
|
||||||
|
- `id`: dependency status ID.
|
||||||
|
- `serverInstanceId`: server instance checked by run.
|
||||||
|
- `pluginId`: plugin that declared the probe.
|
||||||
|
- `probeKey`: logical dependency probe key.
|
||||||
|
- `targetOs`, `targetArch`: target platform metadata.
|
||||||
|
- `state`: dependency state such as present, missing, failed, or unknown.
|
||||||
|
- `required`: whether the probe is required for the runtime profile.
|
||||||
|
- `installPlanKey`: optional typed install plan key.
|
||||||
|
- `message`: bounded safe status.
|
||||||
|
- `checkedAt`, `updatedAt`: observation times.
|
||||||
|
|
||||||
|
Dependency checks and installs are queued as run jobs with logical `dependencies/...` or `dependencies/install/...` target keys. Install jobs must use typed plugin-declared plans and must not carry arbitrary shell snippets.
|
||||||
|
|
||||||
## Job
|
## Job
|
||||||
|
|
||||||
- `id`: job ID.
|
- `id`: job ID.
|
||||||
@@ -96,6 +141,10 @@ Lifecycle workflow jobs use fixed capabilities:
|
|||||||
- `process.install`: dispatched by server create workflow and projects successful terminal results to `ready`.
|
- `process.install`: dispatched by server create workflow and projects successful terminal results to `ready`.
|
||||||
- `process.start`: dispatched by server start workflow and projects successful terminal results to `running`.
|
- `process.start`: dispatched by server start workflow and projects successful terminal results to `running`.
|
||||||
- `process.stop`: dispatched by server stop workflow and projects successful terminal results to `stopped`.
|
- `process.stop`: dispatched by server stop workflow and projects successful terminal results to `stopped`.
|
||||||
|
- `run.self-update`: dispatched by runtime distribution APIs with an approved artifact ref and checksum.
|
||||||
|
- `dependencies.check`: dispatched by dependency check APIs for a declared probe key.
|
||||||
|
- `dependencies.install`: dispatched by dependency install APIs for a declared typed install plan.
|
||||||
|
- `logs.backfill`: dispatched by historical log APIs for a declared source key and checkpoint ref.
|
||||||
|
|
||||||
Failed or cancelled lifecycle jobs project the server instance to `failed`. Active start/stop jobs are visible through job metadata; this change does not add separate `starting` or `stopping` server states.
|
Failed or cancelled lifecycle jobs project the server instance to `failed`. Active start/stop jobs are visible through job metadata; this change does not add separate `starting` or `stopping` server states.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ type RunCapabilityReport struct {
|
|||||||
type RunControlHelloRequest struct {
|
type RunControlHelloRequest struct {
|
||||||
RegistrationToken string `json:"registrationToken"`
|
RegistrationToken string `json:"registrationToken"`
|
||||||
RunEndpointID string `json:"runEndpointId"`
|
RunEndpointID string `json:"runEndpointId"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||||
|
PluginID string `json:"pluginId,omitempty"`
|
||||||
|
ComponentKind domain.DistributionComponentKind `json:"componentKind,omitempty"`
|
||||||
|
ComponentKey string `json:"componentKey,omitempty"`
|
||||||
|
KeyGeneration int `json:"keyGeneration,omitempty"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Status domain.RunEndpointStatus `json:"status"`
|
Status domain.RunEndpointStatus `json:"status"`
|
||||||
@@ -52,6 +57,11 @@ func (request RunControlHelloRequest) ToDomain() domain.RunControlHello {
|
|||||||
return domain.RunControlHello{
|
return domain.RunControlHello{
|
||||||
RegistrationToken: request.RegistrationToken,
|
RegistrationToken: request.RegistrationToken,
|
||||||
RunEndpointID: request.RunEndpointID,
|
RunEndpointID: request.RunEndpointID,
|
||||||
|
ServerInstanceID: request.ServerInstanceID,
|
||||||
|
PluginID: request.PluginID,
|
||||||
|
ComponentKind: request.ComponentKind,
|
||||||
|
ComponentKey: request.ComponentKey,
|
||||||
|
KeyGeneration: request.KeyGeneration,
|
||||||
DisplayName: request.DisplayName,
|
DisplayName: request.DisplayName,
|
||||||
Version: request.Version,
|
Version: request.Version,
|
||||||
Status: request.Status,
|
Status: request.Status,
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunDistributionGenerateRequest struct {
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunUpdateRequest struct {
|
||||||
|
ArtifactID string `json:"artifactId"`
|
||||||
|
Checksum string `json:"checksum,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DependencyJobRequest struct {
|
||||||
|
ProbeKey string `json:"probeKey"`
|
||||||
|
InstallPlanKey string `json:"installPlanKey,omitempty"`
|
||||||
|
TargetOS string `json:"targetOs,omitempty"`
|
||||||
|
TargetArch string `json:"targetArch,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogBackfillRequest struct {
|
||||||
|
SourceKey string `json:"sourceKey"`
|
||||||
|
CheckpointRef string `json:"checkpointRef,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerDownloadRequest struct {
|
||||||
|
ProfileKey string `json:"profileKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerBuildRequest struct {
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
RepositoryURL string `json:"repositoryUrl"`
|
||||||
|
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentKeyResetRequest struct {
|
||||||
|
ComponentKind string `json:"componentKind"`
|
||||||
|
ComponentKey string `json:"componentKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentKeyResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
ComponentKind string `json:"componentKind"`
|
||||||
|
ComponentKey string `json:"componentKey,omitempty"`
|
||||||
|
SecretRef string `json:"secretRef"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Generation int `json:"generation"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
ResetAt time.Time `json:"resetAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerRuntimeActionResponse struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerRuntimeActionsResponse struct {
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
RunEndpointID string `json:"runEndpointId"`
|
||||||
|
RunStatus string `json:"runStatus"`
|
||||||
|
Actions []ServerRuntimeActionResponse `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunDistributionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
RunEndpointID string `json:"runEndpointId"`
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
PackageFormat string `json:"packageFormat"`
|
||||||
|
ArtifactID string `json:"artifactId"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
KeyGeneration int `json:"keyGeneration"`
|
||||||
|
SecretRef string `json:"secretRef"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerDistributionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
RepositoryURL string `json:"repositoryUrl"`
|
||||||
|
SourceRevision string `json:"sourceRevision"`
|
||||||
|
BuildJobID string `json:"buildJobId"`
|
||||||
|
ArtifactID string `json:"artifactId"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
KeyGeneration int `json:"keyGeneration"`
|
||||||
|
SecretRef string `json:"secretRef"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DependencyStatusResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ProbeKey string `json:"probeKey"`
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
InstallPlanKey string `json:"installPlanKey,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
CheckedAt time.Time `json:"checkedAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerBuildJobResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ProfileKey string `json:"profileKey"`
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
RepositoryURL string `json:"repositoryUrl"`
|
||||||
|
SourceRevision string `json:"sourceRevision"`
|
||||||
|
ArtifactID string `json:"artifactId,omitempty"`
|
||||||
|
Checksum string `json:"checksum,omitempty"`
|
||||||
|
KeyGeneration int `json:"keyGeneration,omitempty"`
|
||||||
|
LogsRef string `json:"logsRef,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunUpdateJobResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
RunEndpointID string `json:"runEndpointId"`
|
||||||
|
ArtifactID string `json:"artifactId"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
JobID string `json:"jobId,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request RunDistributionGenerateRequest) ToDomain(serverInstanceID string) domain.RunDistributionGenerateRequest {
|
||||||
|
return domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: serverInstanceID,
|
||||||
|
TargetOS: request.TargetOS,
|
||||||
|
TargetArch: request.TargetArch,
|
||||||
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request RunUpdateRequest) ToDomain(serverInstanceID string) domain.RunUpdateRequest {
|
||||||
|
return domain.RunUpdateRequest{
|
||||||
|
ServerInstanceID: serverInstanceID,
|
||||||
|
ArtifactID: request.ArtifactID,
|
||||||
|
Checksum: request.Checksum,
|
||||||
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request DependencyJobRequest) ToDomain(serverInstanceID string, install bool) domain.DependencyJobRequest {
|
||||||
|
return domain.DependencyJobRequest{
|
||||||
|
ServerInstanceID: serverInstanceID,
|
||||||
|
ProbeKey: request.ProbeKey,
|
||||||
|
InstallPlanKey: request.InstallPlanKey,
|
||||||
|
TargetOS: request.TargetOS,
|
||||||
|
TargetArch: request.TargetArch,
|
||||||
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
|
Install: install,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request LogBackfillRequest) ToDomain(serverInstanceID string) domain.LogBackfillRequest {
|
||||||
|
return domain.LogBackfillRequest{
|
||||||
|
ServerInstanceID: serverInstanceID,
|
||||||
|
SourceKey: request.SourceKey,
|
||||||
|
CheckpointRef: request.CheckpointRef,
|
||||||
|
Limit: request.Limit,
|
||||||
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request ClientManagerBuildRequest) ToDomain(serverInstanceID string) domain.ClientManagerBuildRequest {
|
||||||
|
return domain.ClientManagerBuildRequest{
|
||||||
|
ServerInstanceID: serverInstanceID,
|
||||||
|
ProfileKey: request.ProfileKey,
|
||||||
|
TargetOS: request.TargetOS,
|
||||||
|
TargetArch: request.TargetArch,
|
||||||
|
RepositoryURL: request.RepositoryURL,
|
||||||
|
SourceRevision: request.SourceRevision,
|
||||||
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request ComponentKeyResetRequest) ToDomain(serverInstanceID string) domain.ComponentKeyResetRequest {
|
||||||
|
return domain.ComponentKeyResetRequest{
|
||||||
|
ServerInstanceID: serverInstanceID,
|
||||||
|
ComponentKind: domain.DistributionComponentKind(request.ComponentKind),
|
||||||
|
ComponentKey: request.ComponentKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ComponentKeyFromDomain(key domain.EncryptedComponentKey) ComponentKeyResponse {
|
||||||
|
return ComponentKeyResponse{
|
||||||
|
ID: key.ID,
|
||||||
|
ServerInstanceID: key.ServerInstanceID,
|
||||||
|
ComponentKind: string(key.ComponentKind),
|
||||||
|
ComponentKey: key.ComponentKey,
|
||||||
|
SecretRef: key.SecretRef,
|
||||||
|
Fingerprint: key.Fingerprint,
|
||||||
|
Generation: key.Generation,
|
||||||
|
Status: string(key.Status),
|
||||||
|
CreatedAt: key.CreatedAt,
|
||||||
|
UpdatedAt: key.UpdatedAt,
|
||||||
|
ResetAt: key.ResetAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServerRuntimeActionsFromDomain(actions domain.ServerRuntimeActions) ServerRuntimeActionsResponse {
|
||||||
|
actions = domain.CopyServerRuntimeActions(actions)
|
||||||
|
items := make([]ServerRuntimeActionResponse, len(actions.Actions))
|
||||||
|
for i, action := range actions.Actions {
|
||||||
|
items[i] = ServerRuntimeActionResponse{Key: action.Key, Label: action.Label, Available: action.Available, Reason: action.Reason}
|
||||||
|
}
|
||||||
|
return ServerRuntimeActionsResponse{
|
||||||
|
ServerInstanceID: actions.ServerInstanceID,
|
||||||
|
PluginID: actions.PluginID,
|
||||||
|
RunEndpointID: actions.RunEndpointID,
|
||||||
|
RunStatus: string(actions.RunStatus),
|
||||||
|
Actions: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunDistributionFromDomain(distribution domain.RunDistribution) RunDistributionResponse {
|
||||||
|
return RunDistributionResponse{
|
||||||
|
ID: distribution.ID,
|
||||||
|
ServerInstanceID: distribution.ServerInstanceID,
|
||||||
|
PluginID: distribution.PluginID,
|
||||||
|
RunEndpointID: distribution.RunEndpointID,
|
||||||
|
TargetOS: distribution.TargetOS,
|
||||||
|
TargetArch: distribution.TargetArch,
|
||||||
|
PackageFormat: distribution.PackageFormat,
|
||||||
|
ArtifactID: distribution.ArtifactID,
|
||||||
|
Checksum: distribution.Checksum,
|
||||||
|
KeyGeneration: distribution.KeyGeneration,
|
||||||
|
SecretRef: distribution.SecretRef,
|
||||||
|
Status: string(distribution.Status),
|
||||||
|
CreatedAt: distribution.CreatedAt,
|
||||||
|
UpdatedAt: distribution.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistribution) ClientManagerDistributionResponse {
|
||||||
|
return ClientManagerDistributionResponse{
|
||||||
|
ID: distribution.ID,
|
||||||
|
ServerInstanceID: distribution.ServerInstanceID,
|
||||||
|
PluginID: distribution.PluginID,
|
||||||
|
ProfileKey: distribution.ProfileKey,
|
||||||
|
TargetOS: distribution.TargetOS,
|
||||||
|
TargetArch: distribution.TargetArch,
|
||||||
|
RepositoryURL: distribution.RepositoryURL,
|
||||||
|
SourceRevision: distribution.SourceRevision,
|
||||||
|
BuildJobID: distribution.BuildJobID,
|
||||||
|
ArtifactID: distribution.ArtifactID,
|
||||||
|
Checksum: distribution.Checksum,
|
||||||
|
KeyGeneration: distribution.KeyGeneration,
|
||||||
|
SecretRef: distribution.SecretRef,
|
||||||
|
Status: string(distribution.Status),
|
||||||
|
CreatedAt: distribution.CreatedAt,
|
||||||
|
UpdatedAt: distribution.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatusResponse {
|
||||||
|
return DependencyStatusResponse{
|
||||||
|
ID: status.ID,
|
||||||
|
ServerInstanceID: status.ServerInstanceID,
|
||||||
|
PluginID: status.PluginID,
|
||||||
|
ProbeKey: status.ProbeKey,
|
||||||
|
TargetOS: status.TargetOS,
|
||||||
|
TargetArch: status.TargetArch,
|
||||||
|
State: string(status.State),
|
||||||
|
Required: status.Required,
|
||||||
|
InstallPlanKey: status.InstallPlanKey,
|
||||||
|
Message: status.Message,
|
||||||
|
CheckedAt: status.CheckedAt,
|
||||||
|
UpdatedAt: status.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJobResponse {
|
||||||
|
return ClientManagerBuildJobResponse{
|
||||||
|
ID: job.ID,
|
||||||
|
ServerInstanceID: job.ServerInstanceID,
|
||||||
|
PluginID: job.PluginID,
|
||||||
|
ProfileKey: job.ProfileKey,
|
||||||
|
TargetOS: job.TargetOS,
|
||||||
|
TargetArch: job.TargetArch,
|
||||||
|
RepositoryURL: job.RepositoryURL,
|
||||||
|
SourceRevision: job.SourceRevision,
|
||||||
|
ArtifactID: job.ArtifactID,
|
||||||
|
Checksum: job.Checksum,
|
||||||
|
KeyGeneration: job.KeyGeneration,
|
||||||
|
LogsRef: job.LogsRef,
|
||||||
|
Status: string(job.Status),
|
||||||
|
CreatedAt: job.CreatedAt,
|
||||||
|
UpdatedAt: job.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJobResponse {
|
||||||
|
return RunUpdateJobResponse{
|
||||||
|
ID: job.ID,
|
||||||
|
ServerInstanceID: job.ServerInstanceID,
|
||||||
|
RunEndpointID: job.RunEndpointID,
|
||||||
|
ArtifactID: job.ArtifactID,
|
||||||
|
Checksum: job.Checksum,
|
||||||
|
JobID: job.JobID,
|
||||||
|
IdempotencyKey: job.IdempotencyKey,
|
||||||
|
Status: string(job.Status),
|
||||||
|
CreatedAt: job.CreatedAt,
|
||||||
|
UpdatedAt: job.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,7 @@ type PluginPermissionsResponse struct {
|
|||||||
Files bool `json:"files"`
|
Files bool `json:"files"`
|
||||||
Jobs bool `json:"jobs"`
|
Jobs bool `json:"jobs"`
|
||||||
Artifacts bool `json:"artifacts"`
|
Artifacts bool `json:"artifacts"`
|
||||||
|
RemoteAccess bool `json:"remoteAccess"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginLifecycleActionsBody struct {
|
type PluginLifecycleActionsBody struct {
|
||||||
@@ -196,6 +197,14 @@ type GamePluginManifestAIBody struct {
|
|||||||
Purposes []string `json:"purposes,omitempty"`
|
Purposes []string `json:"purposes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GamePluginRemoteAccessBody struct {
|
||||||
|
Methods []string `json:"methods,omitempty"`
|
||||||
|
RunCapabilities []string `json:"runCapabilities,omitempty"`
|
||||||
|
DatabaseEngines []string `json:"databaseEngines,omitempty"`
|
||||||
|
RCON bool `json:"rcon,omitempty"`
|
||||||
|
LogTransfer bool `json:"logTransfer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type GamePluginManifestBody struct {
|
type GamePluginManifestBody struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -210,6 +219,7 @@ 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"`
|
||||||
|
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginManifestRegistrationRequest struct {
|
type GamePluginManifestRegistrationRequest struct {
|
||||||
@@ -235,6 +245,7 @@ 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"`
|
||||||
|
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +267,7 @@ 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"`
|
||||||
|
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||||
Status domain.GamePluginStatus `json:"status"`
|
Status domain.GamePluginStatus `json:"status"`
|
||||||
}
|
}
|
||||||
@@ -283,6 +295,7 @@ 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"`
|
||||||
|
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,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"`
|
||||||
@@ -353,6 +366,10 @@ type ServerInstanceCreateRequest struct {
|
|||||||
State domain.ServerInstanceState `json:"state,omitempty"`
|
State domain.ServerInstanceState `json:"state,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServerInstanceUpdateRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ServerMemberRequest struct {
|
type ServerMemberRequest struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
}
|
}
|
||||||
@@ -764,6 +781,7 @@ 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(),
|
||||||
|
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,6 +803,16 @@ func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI {
|
|||||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes)}
|
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAccess {
|
||||||
|
return domain.GamePluginRemoteAccess{
|
||||||
|
Methods: domain.CopyStringSlice(remote.Methods),
|
||||||
|
RunCapabilities: domain.CopyStringSlice(remote.RunCapabilities),
|
||||||
|
DatabaseEngines: domain.CopyStringSlice(remote.DatabaseEngines),
|
||||||
|
RCON: remote.RCON,
|
||||||
|
LogTransfer: remote.LogTransfer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||||
return domain.PluginLifecycleActions{
|
return domain.PluginLifecycleActions{
|
||||||
Install: actions.Install,
|
Install: actions.Install,
|
||||||
@@ -814,6 +842,7 @@ 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),
|
||||||
|
RemoteAccess: request.RemoteAccess.ToDomain(),
|
||||||
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
|
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,6 +859,10 @@ func (request ServerInstanceCreateRequest) ToDomain() domain.ServerInstance {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (request ServerInstanceUpdateRequest) ToDomain() domain.ServerInstanceUpdate {
|
||||||
|
return domain.ServerInstanceUpdate{Name: request.Name}
|
||||||
|
}
|
||||||
|
|
||||||
func (request ServerConfigDiffPreviewRequest) ToDomain(serverInstanceID string) domain.ServerConfigDiffRequest {
|
func (request ServerConfigDiffPreviewRequest) ToDomain(serverInstanceID string) domain.ServerConfigDiffRequest {
|
||||||
return domain.ServerConfigDiffRequest{
|
return domain.ServerConfigDiffRequest{
|
||||||
ServerInstanceID: serverInstanceID,
|
ServerInstanceID: serverInstanceID,
|
||||||
@@ -1045,6 +1078,7 @@ 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,
|
||||||
|
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||||
ValidationViolations: plugin.ValidationViolations,
|
ValidationViolations: plugin.ValidationViolations,
|
||||||
Status: plugin.Status,
|
Status: plugin.Status,
|
||||||
}
|
}
|
||||||
@@ -1132,6 +1166,7 @@ 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,
|
||||||
|
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||||
ValidationViolations: plugin.ValidationViolations,
|
ValidationViolations: plugin.ValidationViolations,
|
||||||
Status: plugin.Status,
|
Status: plugin.Status,
|
||||||
Source: plugin.Source,
|
Source: plugin.Source,
|
||||||
@@ -1408,6 +1443,7 @@ func permissionsFromDomain(permissions domain.PluginPermissions) PluginPermissio
|
|||||||
Files: permissions.Files,
|
Files: permissions.Files,
|
||||||
Jobs: permissions.Jobs,
|
Jobs: permissions.Jobs,
|
||||||
Artifacts: permissions.Artifacts,
|
Artifacts: permissions.Artifacts,
|
||||||
|
RemoteAccess: permissions.RemoteAccess,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1418,6 +1454,18 @@ func permissionsToDomain(permissions PluginPermissionsResponse) domain.PluginPer
|
|||||||
Files: permissions.Files,
|
Files: permissions.Files,
|
||||||
Jobs: permissions.Jobs,
|
Jobs: permissions.Jobs,
|
||||||
Artifacts: permissions.Artifacts,
|
Artifacts: permissions.Artifacts,
|
||||||
|
RemoteAccess: permissions.RemoteAccess,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteAccessFromDomain(remote domain.GamePluginRemoteAccess) GamePluginRemoteAccessBody {
|
||||||
|
remote = domain.CopyGamePluginRemoteAccess(remote)
|
||||||
|
return GamePluginRemoteAccessBody{
|
||||||
|
Methods: remote.Methods,
|
||||||
|
RunCapabilities: remote.RunCapabilities,
|
||||||
|
DatabaseEngines: remote.DatabaseEngines,
|
||||||
|
RCON: remote.RCON,
|
||||||
|
LogTransfer: remote.LogTransfer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RuntimeBinding struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||||
|
ProfileKey string `json:"profileKey" db:"profile_key"`
|
||||||
|
Mode string `json:"mode" db:"mode"`
|
||||||
|
Bindings map[string]string `json:"bindings" db:"bindings"`
|
||||||
|
MissingKeys []string `json:"missingKeys" db:"missing_keys"`
|
||||||
|
Status domain.RuntimeBindingStatus `json:"status" db:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RuntimeBinding) TableName() string { return "runtime_bindings" }
|
||||||
|
|
||||||
|
type EncryptedComponentKey struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
ComponentKind domain.DistributionComponentKind `json:"componentKind" db:"component_kind"`
|
||||||
|
ComponentKey string `json:"componentKey,omitempty" db:"component_key"`
|
||||||
|
EncryptedKey string `json:"encryptedKey" db:"encrypted_key"`
|
||||||
|
KeyHash string `json:"keyHash" db:"key_hash"`
|
||||||
|
Fingerprint string `json:"fingerprint" db:"fingerprint"`
|
||||||
|
SecretRef string `json:"secretRef" db:"secret_ref"`
|
||||||
|
Generation int `json:"generation" db:"generation"`
|
||||||
|
Status domain.ComponentKeyStatus `json:"status" db:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
ResetAt time.Time `json:"resetAt,omitempty" db:"reset_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EncryptedComponentKey) TableName() string { return "encrypted_component_keys" }
|
||||||
|
|
||||||
|
type RunDistribution struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||||
|
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
|
||||||
|
TargetOS string `json:"targetOs" db:"target_os"`
|
||||||
|
TargetArch string `json:"targetArch" db:"target_arch"`
|
||||||
|
PackageFormat string `json:"packageFormat" db:"package_format"`
|
||||||
|
ArtifactID string `json:"artifactId" db:"artifact_id"`
|
||||||
|
Checksum string `json:"checksum" db:"checksum"`
|
||||||
|
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
|
||||||
|
SecretRef string `json:"secretRef" db:"secret_ref"`
|
||||||
|
Status domain.DistributionStatus `json:"status" db:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RunDistribution) TableName() string { return "run_distributions" }
|
||||||
|
|
||||||
|
type ClientManagerDistribution struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||||
|
ProfileKey string `json:"profileKey" db:"profile_key"`
|
||||||
|
TargetOS string `json:"targetOs" db:"target_os"`
|
||||||
|
TargetArch string `json:"targetArch" db:"target_arch"`
|
||||||
|
RepositoryURL string `json:"repositoryUrl" db:"repository_url"`
|
||||||
|
SourceRevision string `json:"sourceRevision" db:"source_revision"`
|
||||||
|
BuildJobID string `json:"buildJobId" db:"build_job_id"`
|
||||||
|
ArtifactID string `json:"artifactId" db:"artifact_id"`
|
||||||
|
Checksum string `json:"checksum" db:"checksum"`
|
||||||
|
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
|
||||||
|
SecretRef string `json:"secretRef" db:"secret_ref"`
|
||||||
|
Status domain.DistributionStatus `json:"status" db:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ClientManagerDistribution) TableName() string { return "client_manager_distributions" }
|
||||||
|
|
||||||
|
type DependencyStatus struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||||
|
ProbeKey string `json:"probeKey" db:"probe_key"`
|
||||||
|
TargetOS string `json:"targetOs" db:"target_os"`
|
||||||
|
TargetArch string `json:"targetArch" db:"target_arch"`
|
||||||
|
State domain.DependencyState `json:"state" db:"state"`
|
||||||
|
Required bool `json:"required" db:"required"`
|
||||||
|
InstallPlanKey string `json:"installPlanKey,omitempty" db:"install_plan_key"`
|
||||||
|
Message string `json:"message,omitempty" db:"message"`
|
||||||
|
CheckedAt time.Time `json:"checkedAt" db:"checked_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DependencyStatus) TableName() string { return "dependency_statuses" }
|
||||||
|
|
||||||
|
type ClientManagerBuildJob struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||||
|
ProfileKey string `json:"profileKey" db:"profile_key"`
|
||||||
|
TargetOS string `json:"targetOs" db:"target_os"`
|
||||||
|
TargetArch string `json:"targetArch" db:"target_arch"`
|
||||||
|
RepositoryURL string `json:"repositoryUrl" db:"repository_url"`
|
||||||
|
SourceRevision string `json:"sourceRevision" db:"source_revision"`
|
||||||
|
ArtifactID string `json:"artifactId" db:"artifact_id"`
|
||||||
|
Checksum string `json:"checksum" db:"checksum"`
|
||||||
|
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
|
||||||
|
LogsRef string `json:"logsRef,omitempty" db:"logs_ref"`
|
||||||
|
Status domain.DistributionJobStatus `json:"status" db:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ClientManagerBuildJob) TableName() string { return "client_manager_build_jobs" }
|
||||||
|
|
||||||
|
type RunUpdateJob struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||||
|
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
|
||||||
|
ArtifactID string `json:"artifactId" db:"artifact_id"`
|
||||||
|
Checksum string `json:"checksum" db:"checksum"`
|
||||||
|
JobID string `json:"jobId" db:"job_id"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"`
|
||||||
|
Status domain.DistributionJobStatus `json:"status" db:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RunUpdateJob) TableName() string { return "run_update_jobs" }
|
||||||
|
|
||||||
|
func RuntimeBindingFromDomain(binding domain.RuntimeBinding) RuntimeBinding {
|
||||||
|
binding = domain.CopyRuntimeBinding(binding)
|
||||||
|
return RuntimeBinding{
|
||||||
|
ID: binding.ID,
|
||||||
|
ServerInstanceID: binding.ServerInstanceID,
|
||||||
|
PluginID: binding.PluginID,
|
||||||
|
ProfileKey: binding.ProfileKey,
|
||||||
|
Mode: binding.Mode,
|
||||||
|
Bindings: binding.Bindings,
|
||||||
|
MissingKeys: binding.MissingKeys,
|
||||||
|
Status: binding.Status,
|
||||||
|
CreatedAt: binding.CreatedAt,
|
||||||
|
UpdatedAt: binding.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (binding RuntimeBinding) ToDomain() domain.RuntimeBinding {
|
||||||
|
return domain.RuntimeBinding{
|
||||||
|
ID: binding.ID,
|
||||||
|
ServerInstanceID: binding.ServerInstanceID,
|
||||||
|
PluginID: binding.PluginID,
|
||||||
|
ProfileKey: binding.ProfileKey,
|
||||||
|
Mode: binding.Mode,
|
||||||
|
Bindings: domain.CopyStringMap(binding.Bindings),
|
||||||
|
MissingKeys: domain.CopyStringSlice(binding.MissingKeys),
|
||||||
|
Status: binding.Status,
|
||||||
|
CreatedAt: binding.CreatedAt,
|
||||||
|
UpdatedAt: binding.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncryptedComponentKeyFromDomain(key domain.EncryptedComponentKey) EncryptedComponentKey {
|
||||||
|
return EncryptedComponentKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key EncryptedComponentKey) ToDomain() domain.EncryptedComponentKey {
|
||||||
|
return domain.EncryptedComponentKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunDistributionFromDomain(distribution domain.RunDistribution) RunDistribution {
|
||||||
|
return RunDistribution(distribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (distribution RunDistribution) ToDomain() domain.RunDistribution {
|
||||||
|
return domain.RunDistribution(distribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistribution) ClientManagerDistribution {
|
||||||
|
return ClientManagerDistribution(distribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (distribution ClientManagerDistribution) ToDomain() domain.ClientManagerDistribution {
|
||||||
|
return domain.ClientManagerDistribution(distribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatus {
|
||||||
|
return DependencyStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status DependencyStatus) ToDomain() domain.DependencyStatus {
|
||||||
|
return domain.DependencyStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJob {
|
||||||
|
return ClientManagerBuildJob(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job ClientManagerBuildJob) ToDomain() domain.ClientManagerBuildJob {
|
||||||
|
return domain.ClientManagerBuildJob(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJob {
|
||||||
|
return RunUpdateJob(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job RunUpdateJob) ToDomain() domain.RunUpdateJob {
|
||||||
|
return domain.RunUpdateJob(job)
|
||||||
|
}
|
||||||
@@ -69,6 +69,21 @@ type PluginPermissions struct {
|
|||||||
Jobs bool `json:"jobs" db:"jobs"`
|
Jobs bool `json:"jobs" db:"jobs"`
|
||||||
// Artifacts allows artifact metadata and transfer references.
|
// Artifacts allows artifact metadata and transfer references.
|
||||||
Artifacts bool `json:"artifacts" db:"artifacts"`
|
Artifacts bool `json:"artifacts" db:"artifacts"`
|
||||||
|
// RemoteAccess allows platform-mediated remote server operations.
|
||||||
|
RemoteAccess bool `json:"remoteAccess" db:"remote_access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GamePluginRemoteAccess struct {
|
||||||
|
// Methods lists declared remote access transports such as ftp, rsync, or run.
|
||||||
|
Methods []string `json:"methods" db:"methods"`
|
||||||
|
// RunCapabilities lists remote run job capabilities enabled by the plugin.
|
||||||
|
RunCapabilities []string `json:"runCapabilities" db:"run_capabilities"`
|
||||||
|
// DatabaseEngines lists database engines supported through run-mediated reads.
|
||||||
|
DatabaseEngines []string `json:"databaseEngines" db:"database_engines"`
|
||||||
|
// RCON indicates that platform-mediated RCON commands are supported.
|
||||||
|
RCON bool `json:"rcon" db:"rcon"`
|
||||||
|
// LogTransfer indicates that run-mediated log transfer is supported.
|
||||||
|
LogTransfer bool `json:"logTransfer" db:"log_transfer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginLifecycleActions struct {
|
type PluginLifecycleActions struct {
|
||||||
@@ -128,6 +143,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"`
|
||||||
|
// RemoteAccess stores plugin-declared remote access metadata.
|
||||||
|
RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"`
|
||||||
// ValidationViolations stores safe validation findings for invalid plugins.
|
// ValidationViolations stores safe validation findings for invalid plugins.
|
||||||
ValidationViolations []string `json:"validationViolations" db:"validation_violations"`
|
ValidationViolations []string `json:"validationViolations" db:"validation_violations"`
|
||||||
// Status is the plugin lifecycle status.
|
// Status is the plugin lifecycle status.
|
||||||
@@ -377,6 +394,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,
|
||||||
|
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||||
ValidationViolations: plugin.ValidationViolations,
|
ValidationViolations: plugin.ValidationViolations,
|
||||||
Status: plugin.Status,
|
Status: plugin.Status,
|
||||||
}
|
}
|
||||||
@@ -400,6 +418,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),
|
||||||
|
RemoteAccess: plugin.RemoteAccess.ToDomain(),
|
||||||
ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations),
|
ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations),
|
||||||
Status: plugin.Status,
|
Status: plugin.Status,
|
||||||
}
|
}
|
||||||
@@ -464,6 +483,7 @@ func (permissions PluginPermissions) ToDomain() domain.PluginPermissions {
|
|||||||
Files: permissions.Files,
|
Files: permissions.Files,
|
||||||
Jobs: permissions.Jobs,
|
Jobs: permissions.Jobs,
|
||||||
Artifacts: permissions.Artifacts,
|
Artifacts: permissions.Artifacts,
|
||||||
|
RemoteAccess: permissions.RemoteAccess,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +494,28 @@ func permissionsFromDomain(permissions domain.PluginPermissions) PluginPermissio
|
|||||||
Files: permissions.Files,
|
Files: permissions.Files,
|
||||||
Jobs: permissions.Jobs,
|
Jobs: permissions.Jobs,
|
||||||
Artifacts: permissions.Artifacts,
|
Artifacts: permissions.Artifacts,
|
||||||
|
RemoteAccess: permissions.RemoteAccess,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (remote GamePluginRemoteAccess) ToDomain() domain.GamePluginRemoteAccess {
|
||||||
|
return domain.GamePluginRemoteAccess{
|
||||||
|
Methods: domain.CopyStringSlice(remote.Methods),
|
||||||
|
RunCapabilities: domain.CopyStringSlice(remote.RunCapabilities),
|
||||||
|
DatabaseEngines: domain.CopyStringSlice(remote.DatabaseEngines),
|
||||||
|
RCON: remote.RCON,
|
||||||
|
LogTransfer: remote.LogTransfer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteAccessFromDomain(remote domain.GamePluginRemoteAccess) GamePluginRemoteAccess {
|
||||||
|
remote = domain.CopyGamePluginRemoteAccess(remote)
|
||||||
|
return GamePluginRemoteAccess{
|
||||||
|
Methods: remote.Methods,
|
||||||
|
RunCapabilities: remote.RunCapabilities,
|
||||||
|
DatabaseEngines: remote.DatabaseEngines,
|
||||||
|
RCON: remote.RCON,
|
||||||
|
LogTransfer: remote.LogTransfer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,55 @@ type ArtifactRepository interface {
|
|||||||
Update(domain.Artifact) error
|
Update(domain.Artifact) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeBindingRepository interface {
|
||||||
|
Create(domain.RuntimeBinding) error
|
||||||
|
Get(id string) (domain.RuntimeBinding, error)
|
||||||
|
List(domain.RuntimeBindingFilter) ([]domain.RuntimeBinding, error)
|
||||||
|
Update(domain.RuntimeBinding) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type EncryptedComponentKeyRepository interface {
|
||||||
|
Create(domain.EncryptedComponentKey) error
|
||||||
|
Get(id string) (domain.EncryptedComponentKey, error)
|
||||||
|
List(domain.EncryptedComponentKeyFilter) ([]domain.EncryptedComponentKey, error)
|
||||||
|
Update(domain.EncryptedComponentKey) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunDistributionRepository interface {
|
||||||
|
Create(domain.RunDistribution) error
|
||||||
|
Get(id string) (domain.RunDistribution, error)
|
||||||
|
List(domain.RunDistributionFilter) ([]domain.RunDistribution, error)
|
||||||
|
Update(domain.RunDistribution) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerDistributionRepository interface {
|
||||||
|
Create(domain.ClientManagerDistribution) error
|
||||||
|
Get(id string) (domain.ClientManagerDistribution, error)
|
||||||
|
List(domain.ClientManagerDistributionFilter) ([]domain.ClientManagerDistribution, error)
|
||||||
|
Update(domain.ClientManagerDistribution) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type DependencyStatusRepository interface {
|
||||||
|
Create(domain.DependencyStatus) error
|
||||||
|
Get(id string) (domain.DependencyStatus, error)
|
||||||
|
List(domain.DependencyStatusFilter) ([]domain.DependencyStatus, error)
|
||||||
|
Update(domain.DependencyStatus) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientManagerBuildJobRepository interface {
|
||||||
|
Create(domain.ClientManagerBuildJob) error
|
||||||
|
Get(id string) (domain.ClientManagerBuildJob, error)
|
||||||
|
List(domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error)
|
||||||
|
Update(domain.ClientManagerBuildJob) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunUpdateJobRepository interface {
|
||||||
|
Create(domain.RunUpdateJob) error
|
||||||
|
Get(id string) (domain.RunUpdateJob, error)
|
||||||
|
List(domain.RunUpdateJobFilter) ([]domain.RunUpdateJob, error)
|
||||||
|
Update(domain.RunUpdateJob) error
|
||||||
|
}
|
||||||
|
|
||||||
type LogStreamRepository interface {
|
type LogStreamRepository interface {
|
||||||
Create(domain.LogStream) error
|
Create(domain.LogStream) error
|
||||||
Get(id string) (domain.LogStream, error)
|
Get(id string) (domain.LogStream, error)
|
||||||
@@ -85,6 +134,13 @@ type Store interface {
|
|||||||
RunEndpoints() RunEndpointRepository
|
RunEndpoints() RunEndpointRepository
|
||||||
Jobs() JobRepository
|
Jobs() JobRepository
|
||||||
Artifacts() ArtifactRepository
|
Artifacts() ArtifactRepository
|
||||||
|
RuntimeBindings() RuntimeBindingRepository
|
||||||
|
EncryptedComponentKeys() EncryptedComponentKeyRepository
|
||||||
|
RunDistributions() RunDistributionRepository
|
||||||
|
ClientManagerDistributions() ClientManagerDistributionRepository
|
||||||
|
DependencyStatuses() DependencyStatusRepository
|
||||||
|
ClientManagerBuildJobs() ClientManagerBuildJobRepository
|
||||||
|
RunUpdateJobs() RunUpdateJobRepository
|
||||||
LogStreams() LogStreamRepository
|
LogStreams() LogStreamRepository
|
||||||
AuditEvents() AuditEventRepository
|
AuditEvents() AuditEventRepository
|
||||||
}
|
}
|
||||||
@@ -97,6 +153,13 @@ type MemoryStore struct {
|
|||||||
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
||||||
jobs *memoryJobRepository
|
jobs *memoryJobRepository
|
||||||
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
||||||
|
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
|
||||||
|
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
|
||||||
|
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
|
||||||
|
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
|
||||||
|
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
|
||||||
|
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||||
|
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||||
}
|
}
|
||||||
@@ -134,6 +197,41 @@ func NewMemoryStore() *MemoryStore {
|
|||||||
domain.CopyArtifact,
|
domain.CopyArtifact,
|
||||||
matchArtifact,
|
matchArtifact,
|
||||||
),
|
),
|
||||||
|
runtimeBindings: newMemoryRepository(
|
||||||
|
func(binding domain.RuntimeBinding) string { return binding.ID },
|
||||||
|
domain.CopyRuntimeBinding,
|
||||||
|
matchRuntimeBinding,
|
||||||
|
),
|
||||||
|
componentKeys: newMemoryRepository(
|
||||||
|
func(key domain.EncryptedComponentKey) string { return key.ID },
|
||||||
|
domain.CopyEncryptedComponentKey,
|
||||||
|
matchEncryptedComponentKey,
|
||||||
|
),
|
||||||
|
runDists: newMemoryRepository(
|
||||||
|
func(distribution domain.RunDistribution) string { return distribution.ID },
|
||||||
|
domain.CopyRunDistribution,
|
||||||
|
matchRunDistribution,
|
||||||
|
),
|
||||||
|
clientDists: newMemoryRepository(
|
||||||
|
func(distribution domain.ClientManagerDistribution) string { return distribution.ID },
|
||||||
|
domain.CopyClientManagerDistribution,
|
||||||
|
matchClientManagerDistribution,
|
||||||
|
),
|
||||||
|
dependencies: newMemoryRepository(
|
||||||
|
func(status domain.DependencyStatus) string { return status.ID },
|
||||||
|
domain.CopyDependencyStatus,
|
||||||
|
matchDependencyStatus,
|
||||||
|
),
|
||||||
|
buildJobs: newMemoryRepository(
|
||||||
|
func(job domain.ClientManagerBuildJob) string { return job.ID },
|
||||||
|
domain.CopyClientManagerBuildJob,
|
||||||
|
matchClientManagerBuildJob,
|
||||||
|
),
|
||||||
|
updateJobs: newMemoryRepository(
|
||||||
|
func(job domain.RunUpdateJob) string { return job.ID },
|
||||||
|
domain.CopyRunUpdateJob,
|
||||||
|
matchRunUpdateJob,
|
||||||
|
),
|
||||||
logStreams: newMemoryRepository(
|
logStreams: newMemoryRepository(
|
||||||
func(stream domain.LogStream) string { return stream.ID },
|
func(stream domain.LogStream) string { return stream.ID },
|
||||||
domain.CopyLogStream,
|
domain.CopyLogStream,
|
||||||
@@ -154,6 +252,19 @@ func (store *MemoryStore) ServerInstances() ServerInstanceRepository { return st
|
|||||||
func (store *MemoryStore) RunEndpoints() RunEndpointRepository { return store.runEndpoints }
|
func (store *MemoryStore) RunEndpoints() RunEndpointRepository { return store.runEndpoints }
|
||||||
func (store *MemoryStore) Jobs() JobRepository { return store.jobs }
|
func (store *MemoryStore) Jobs() JobRepository { return store.jobs }
|
||||||
func (store *MemoryStore) Artifacts() ArtifactRepository { return store.artifacts }
|
func (store *MemoryStore) Artifacts() ArtifactRepository { return store.artifacts }
|
||||||
|
func (store *MemoryStore) RuntimeBindings() RuntimeBindingRepository { return store.runtimeBindings }
|
||||||
|
func (store *MemoryStore) EncryptedComponentKeys() EncryptedComponentKeyRepository {
|
||||||
|
return store.componentKeys
|
||||||
|
}
|
||||||
|
func (store *MemoryStore) RunDistributions() RunDistributionRepository { return store.runDists }
|
||||||
|
func (store *MemoryStore) ClientManagerDistributions() ClientManagerDistributionRepository {
|
||||||
|
return store.clientDists
|
||||||
|
}
|
||||||
|
func (store *MemoryStore) DependencyStatuses() DependencyStatusRepository { return store.dependencies }
|
||||||
|
func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository {
|
||||||
|
return store.buildJobs
|
||||||
|
}
|
||||||
|
func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs }
|
||||||
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
|
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
|
||||||
func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents }
|
func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents }
|
||||||
|
|
||||||
@@ -271,6 +382,9 @@ func matchGamePlugin(plugin domain.GamePlugin, filter domain.GamePluginFilter) b
|
|||||||
}
|
}
|
||||||
|
|
||||||
func matchServerInstance(instance domain.ServerInstance, filter domain.ServerInstanceFilter) bool {
|
func matchServerInstance(instance domain.ServerInstance, filter domain.ServerInstanceFilter) bool {
|
||||||
|
if instance.State == domain.ServerInstanceStateDeleted && filter.State != domain.ServerInstanceStateDeleted {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return (filter.PluginID == "" || instance.PluginID == filter.PluginID) &&
|
return (filter.PluginID == "" || instance.PluginID == filter.PluginID) &&
|
||||||
(filter.RunEndpointID == "" || instance.RunEndpointID == filter.RunEndpointID) &&
|
(filter.RunEndpointID == "" || instance.RunEndpointID == filter.RunEndpointID) &&
|
||||||
(filter.State == "" || instance.State == filter.State) &&
|
(filter.State == "" || instance.State == filter.State) &&
|
||||||
@@ -302,6 +416,51 @@ func matchArtifact(artifact domain.Artifact, filter domain.ArtifactFilter) bool
|
|||||||
(filter.State == "" || artifact.State == filter.State)
|
(filter.State == "" || artifact.State == filter.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func matchRuntimeBinding(binding domain.RuntimeBinding, filter domain.RuntimeBindingFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || binding.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.ProfileKey == "" || binding.ProfileKey == filter.ProfileKey) &&
|
||||||
|
(filter.Status == "" || binding.Status == filter.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchEncryptedComponentKey(key domain.EncryptedComponentKey, filter domain.EncryptedComponentKeyFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || key.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.ComponentKind == "" || key.ComponentKind == filter.ComponentKind) &&
|
||||||
|
(filter.ComponentKey == "" || key.ComponentKey == filter.ComponentKey) &&
|
||||||
|
(filter.Status == "" || key.Status == filter.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchRunDistribution(distribution domain.RunDistribution, filter domain.RunDistributionFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || distribution.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.TargetOS == "" || distribution.TargetOS == filter.TargetOS) &&
|
||||||
|
(filter.TargetArch == "" || distribution.TargetArch == filter.TargetArch) &&
|
||||||
|
(filter.Status == "" || distribution.Status == filter.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchClientManagerDistribution(distribution domain.ClientManagerDistribution, filter domain.ClientManagerDistributionFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || distribution.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.ProfileKey == "" || distribution.ProfileKey == filter.ProfileKey) &&
|
||||||
|
(filter.TargetOS == "" || distribution.TargetOS == filter.TargetOS) &&
|
||||||
|
(filter.TargetArch == "" || distribution.TargetArch == filter.TargetArch) &&
|
||||||
|
(filter.Status == "" || distribution.Status == filter.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchDependencyStatus(status domain.DependencyStatus, filter domain.DependencyStatusFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || status.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.ProbeKey == "" || status.ProbeKey == filter.ProbeKey) &&
|
||||||
|
(filter.State == "" || status.State == filter.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchClientManagerBuildJob(job domain.ClientManagerBuildJob, filter domain.ClientManagerBuildJobFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.ProfileKey == "" || job.ProfileKey == filter.ProfileKey) &&
|
||||||
|
(filter.Status == "" || job.Status == filter.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchRunUpdateJob(job domain.RunUpdateJob, filter domain.RunUpdateJobFilter) bool {
|
||||||
|
return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
(filter.Status == "" || job.Status == filter.Status)
|
||||||
|
}
|
||||||
|
|
||||||
func matchLogStream(stream domain.LogStream, filter domain.LogStreamFilter) bool {
|
func matchLogStream(stream domain.LogStream, filter domain.LogStreamFilter) bool {
|
||||||
return (filter.ServerInstanceID == "" || stream.ServerInstanceID == filter.ServerInstanceID) &&
|
return (filter.ServerInstanceID == "" || stream.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
|
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request
|
|||||||
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
|
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
|
||||||
return domain.ArtifactDownloadReference{}, err
|
return domain.ArtifactDownloadReference{}, err
|
||||||
}
|
}
|
||||||
|
if err := svc.auditArtifactDownload(sessionID, artifact); err != nil {
|
||||||
|
return domain.ArtifactDownloadReference{}, err
|
||||||
|
}
|
||||||
return domain.CopyArtifactDownloadReference(reference), nil
|
return domain.CopyArtifactDownloadReference(reference), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +157,10 @@ func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) {
|
|||||||
svc.artifactMu.Lock()
|
svc.artifactMu.Lock()
|
||||||
defer svc.artifactMu.Unlock()
|
defer svc.artifactMu.Unlock()
|
||||||
|
|
||||||
|
if payload, exists := svc.artifactPayloads[artifactID]; exists {
|
||||||
|
return domain.CopyBytes(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers))
|
sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers))
|
||||||
for _, session := range svc.artifactTransfers {
|
for _, session := range svc.artifactTransfers {
|
||||||
if session.ArtifactID == artifactID && session.Completed {
|
if session.ArtifactID == artifactID && session.Completed {
|
||||||
|
|||||||
@@ -19,6 +19,27 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
|
|||||||
if err := validator.ValidateRunControlHello(hello); err != nil {
|
if err := validator.ValidateRunControlHello(hello); err != nil {
|
||||||
return domain.RunControlHelloResult{}, err
|
return domain.RunControlHelloResult{}, err
|
||||||
}
|
}
|
||||||
|
if hasComponentAuthIdentity(hello) {
|
||||||
|
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
|
||||||
|
ServerInstanceID: hello.ServerInstanceID,
|
||||||
|
ComponentKind: hello.ComponentKind,
|
||||||
|
ComponentKey: hello.ComponentKey,
|
||||||
|
Generation: hello.KeyGeneration,
|
||||||
|
Key: hello.RegistrationToken,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.RunControlHelloResult{}, err
|
||||||
|
}
|
||||||
|
if !auth.Allowed {
|
||||||
|
return domain.CopyRunControlHelloResult(domain.RunControlHelloResult{
|
||||||
|
Accepted: false,
|
||||||
|
RunEndpointID: hello.RunEndpointID,
|
||||||
|
ServerTime: svc.now(),
|
||||||
|
HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds,
|
||||||
|
FeatureFlags: []string{"runtime-key.auth.denied"},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
endpoint := domain.RunEndpoint{
|
endpoint := domain.RunEndpoint{
|
||||||
@@ -60,6 +81,10 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasComponentAuthIdentity(hello domain.RunControlHello) bool {
|
||||||
|
return hello.ServerInstanceID != "" || hello.PluginID != "" || hello.ComponentKind != "" || hello.ComponentKey != "" || hello.KeyGeneration != 0
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) {
|
func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) {
|
||||||
heartbeat = domain.CopyRunControlHeartbeat(heartbeat)
|
heartbeat = domain.CopyRunControlHeartbeat(heartbeat)
|
||||||
if err := validator.ValidateRunControlHeartbeat(heartbeat); err != nil {
|
if err := validator.ValidateRunControlHeartbeat(heartbeat); err != nil {
|
||||||
|
|||||||
@@ -134,6 +134,49 @@ func TestCoreServiceRejectsInvalidRunControlHello(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceRunHelloRejectsStalePackageKeyAfterReset(t *testing.T) {
|
||||||
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
|
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
IdempotencyKey: "idem-control-auth",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate run distribution: %v", err)
|
||||||
|
}
|
||||||
|
pkg := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
|
||||||
|
hello := validRunControlHello()
|
||||||
|
hello.RunEndpointID = instance.RunEndpointID
|
||||||
|
hello.RegistrationToken = pkg.AuthKey
|
||||||
|
hello.ServerInstanceID = instance.ID
|
||||||
|
hello.PluginID = instance.PluginID
|
||||||
|
hello.ComponentKind = domain.DistributionComponentRun
|
||||||
|
hello.KeyGeneration = pkg.KeyGeneration
|
||||||
|
|
||||||
|
result, err := svc.RegisterRunHello(hello)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register current package hello: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Accepted || result.SessionToken == "" {
|
||||||
|
t.Fatalf("expected current package hello to be accepted, got %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentRun,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("reset run key: %v", err)
|
||||||
|
}
|
||||||
|
result, err = svc.RegisterRunHello(hello)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register stale package hello: %v", err)
|
||||||
|
}
|
||||||
|
if result.Accepted || result.SessionToken != "" {
|
||||||
|
t.Fatalf("expected stale package hello to be rejected, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) {
|
func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
hello, err := svc.RegisterRunHello(validRunControlHello())
|
hello, err := svc.RegisterRunHello(validRunControlHello())
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) {
|
||||||
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
|
|
||||||
|
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
IdempotencyKey: "idem-run-generate",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate run distribution: %v", err)
|
||||||
|
}
|
||||||
|
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusAvailable {
|
||||||
|
t.Fatalf("unexpected run distribution: %+v", distribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentRun,
|
||||||
|
Status: domain.ComponentKeyStatusActive,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list component keys: %v", err)
|
||||||
|
}
|
||||||
|
if len(keys) != 1 || keys[0].Generation != 1 || !strings.HasPrefix(keys[0].EncryptedKey, "enc:v1:") {
|
||||||
|
t.Fatalf("expected one active encrypted run key, got %+v", keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
|
||||||
|
if config.AuthKey == "" || config.AuthKey == keys[0].EncryptedKey || strings.Contains(distribution.SecretRef, config.AuthKey) {
|
||||||
|
t.Fatalf("run package key leaked through metadata or was not encrypted, config=%+v key=%+v distribution=%+v", config, keys[0], distribution)
|
||||||
|
}
|
||||||
|
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentRun,
|
||||||
|
Generation: config.KeyGeneration,
|
||||||
|
Key: config.AuthKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate run: %v", err)
|
||||||
|
}
|
||||||
|
if !auth.Allowed {
|
||||||
|
t.Fatalf("expected current run key to authenticate, got %+v", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
IdempotencyKey: "idem-run-generate-second",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate second run distribution: %v", err)
|
||||||
|
}
|
||||||
|
if second.KeyGeneration != 1 || second.SecretRef != distribution.SecretRef {
|
||||||
|
t.Fatalf("expected second package to reuse current singleton key, got first=%+v second=%+v", distribution, second)
|
||||||
|
}
|
||||||
|
keys, err = svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentRun,
|
||||||
|
Status: domain.ComponentKeyStatusActive,
|
||||||
|
})
|
||||||
|
if err != nil || len(keys) != 1 {
|
||||||
|
t.Fatalf("expected one active key after second generation, keys=%+v err=%v", keys, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *testing.T) {
|
||||||
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
|
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
IdempotencyKey: "idem-run-before-reset",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate run distribution: %v", err)
|
||||||
|
}
|
||||||
|
oldConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
|
||||||
|
|
||||||
|
reset, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentRun,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reset run key: %v", err)
|
||||||
|
}
|
||||||
|
if reset.Generation != 2 || reset.Status != domain.ComponentKeyStatusActive {
|
||||||
|
t.Fatalf("expected reset key generation 2, got %+v", reset)
|
||||||
|
}
|
||||||
|
oldDistribution, err := svc.store.RunDistributions().Get(distribution.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get old distribution: %v", err)
|
||||||
|
}
|
||||||
|
if oldDistribution.Status != domain.DistributionStatusRevoked {
|
||||||
|
t.Fatalf("expected old distribution revoked, got %+v", oldDistribution)
|
||||||
|
}
|
||||||
|
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: distribution.ArtifactID}); err == nil || !strings.Contains(err.Error(), "available") {
|
||||||
|
t.Fatalf("expected old artifact download to be unavailable, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentRun,
|
||||||
|
Generation: oldConfig.KeyGeneration,
|
||||||
|
Key: oldConfig.AuthKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate old key: %v", err)
|
||||||
|
}
|
||||||
|
if auth.Allowed || !strings.Contains(auth.Reason, "generation") {
|
||||||
|
t.Fatalf("expected old package authentication denial, got %+v", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
newDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
IdempotencyKey: "idem-run-after-reset",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate run distribution after reset: %v", err)
|
||||||
|
}
|
||||||
|
newConfig := readGeneratedPackageConfig(t, svc, session, newDistribution.ArtifactID)
|
||||||
|
if newDistribution.KeyGeneration != 2 || newConfig.AuthKey == oldConfig.AuthKey {
|
||||||
|
t.Fatalf("expected regenerated package with new generation/key, old=%+v new=%+v", oldConfig, newConfig)
|
||||||
|
}
|
||||||
|
auth, err = svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentRun,
|
||||||
|
Generation: newConfig.KeyGeneration,
|
||||||
|
Key: newConfig.AuthKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate new key: %v", err)
|
||||||
|
}
|
||||||
|
if !auth.Allowed {
|
||||||
|
t.Fatalf("expected regenerated package to authenticate, got %+v", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperations(t *testing.T) {
|
||||||
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
|
runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
IdempotencyKey: "idem-run-for-client",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate run distribution: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil {
|
||||||
|
t.Fatalf("open run download: %v", err)
|
||||||
|
}
|
||||||
|
runConfig := readGeneratedPackageConfig(t, svc, session, runDistribution.ArtifactID)
|
||||||
|
|
||||||
|
clientDistribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
TargetOS: "windows",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||||
|
SourceRevision: "main",
|
||||||
|
IdempotencyKey: "idem-client-manager",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate client-manager distribution: %v", err)
|
||||||
|
}
|
||||||
|
clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID)
|
||||||
|
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusAvailable {
|
||||||
|
t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution)
|
||||||
|
}
|
||||||
|
if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef {
|
||||||
|
t.Fatalf("client-manager must use a distinct key/ref, run=%+v client=%+v", runConfig, clientConfig)
|
||||||
|
}
|
||||||
|
build, err := svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get build job: %v", err)
|
||||||
|
}
|
||||||
|
if build.Status != domain.DistributionJobStatusSucceeded || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
|
||||||
|
t.Fatalf("unexpected build job: %+v", build)
|
||||||
|
}
|
||||||
|
if build.LogsRef == "" || !strings.HasPrefix(build.LogsRef, "artifact://") {
|
||||||
|
t.Fatalf("expected redacted build log artifact ref, got %+v", build)
|
||||||
|
}
|
||||||
|
packagePayload := readClientManagerPackage(t, svc, session, clientDistribution.ArtifactID)
|
||||||
|
if packagePayload.Checkout.CheckoutRef != "branch/main" || packagePayload.Config.AuthKey != clientConfig.AuthKey || packagePayload.KeyFingerprint == "" {
|
||||||
|
t.Fatalf("expected package checkout metadata and injected config, got %+v", packagePayload)
|
||||||
|
}
|
||||||
|
if len(packagePayload.OutputArtifacts) == 0 || packagePayload.BuildLogRef != build.LogsRef {
|
||||||
|
t.Fatalf("expected output artifacts and build log ref, got %+v build=%+v", packagePayload, build)
|
||||||
|
}
|
||||||
|
buildLog := readArtifactString(t, svc, session, strings.TrimPrefix(build.LogsRef, "artifact://"))
|
||||||
|
for _, expected := range []string{"client-manager checkout prepared", "checkoutRef=branch/main", "dependencyCheck=typed build profile accepted", "configInjection=secret ref"} {
|
||||||
|
if !strings.Contains(buildLog, expected) {
|
||||||
|
t.Fatalf("expected build log to contain %q, got %q", expected, buildLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
|
||||||
|
if strings.Contains(buildLog, forbidden) {
|
||||||
|
t.Fatalf("build log leaked forbidden fragment %q: %s", forbidden, buildLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
TargetOS: "darwin",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||||
|
IdempotencyKey: "idem-client-manager-denied",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "targetOs") {
|
||||||
|
t.Fatalf("expected unsupported target denial, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentClientManager,
|
||||||
|
ComponentKey: "scum-client-manager",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("reset client-manager key: %v", err)
|
||||||
|
}
|
||||||
|
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
ComponentKind: domain.DistributionComponentClientManager,
|
||||||
|
ComponentKey: "scum-client-manager",
|
||||||
|
Generation: clientConfig.KeyGeneration,
|
||||||
|
Key: clientConfig.AuthKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate old client key: %v", err)
|
||||||
|
}
|
||||||
|
if auth.Allowed {
|
||||||
|
t.Fatalf("expected old client-manager key to be denied after reset, got %+v", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list audits: %v", err)
|
||||||
|
}
|
||||||
|
actions := map[string]bool{}
|
||||||
|
for _, audit := range audits {
|
||||||
|
actions[audit.Action] = true
|
||||||
|
for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "/Users/"} {
|
||||||
|
if strings.Contains(audit.Summary, forbidden) {
|
||||||
|
t.Fatalf("audit leaked forbidden fragment %q in %+v", forbidden, audit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, action := range []string{"run.generate", "run.download", "client-manager.build", "client-manager.build.denied", "runtime-key.reset"} {
|
||||||
|
if !actions[action] {
|
||||||
|
t.Fatalf("expected audit action %q in %+v", action, audits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
|
||||||
|
t.Helper()
|
||||||
|
svc := newTestCoreService()
|
||||||
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
plugin.SupportedOS = []string{"linux", "windows"}
|
||||||
|
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions,
|
||||||
|
"server.run.distribution",
|
||||||
|
"server.client-manager.manage",
|
||||||
|
"server.dependencies.manage",
|
||||||
|
)
|
||||||
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities,
|
||||||
|
domain.JobCapabilityRunSelfUpdate,
|
||||||
|
domain.JobCapabilityDependenciesCheck,
|
||||||
|
domain.JobCapabilityDependenciesInstall,
|
||||||
|
domain.JobCapabilityLogsBackfill,
|
||||||
|
)
|
||||||
|
plugin.BridgeActions = append(plugin.BridgeActions,
|
||||||
|
string(domain.PluginBridgeActionRunDistribution),
|
||||||
|
string(domain.PluginBridgeActionClientManager),
|
||||||
|
string(domain.PluginBridgeActionDependenciesRequest),
|
||||||
|
string(domain.PluginBridgeActionLogsBackfillRequest),
|
||||||
|
)
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("update plugin fixture: %v", err)
|
||||||
|
}
|
||||||
|
endpoint.Capabilities = append(endpoint.Capabilities,
|
||||||
|
domain.JobCapabilityRunSelfUpdate,
|
||||||
|
domain.JobCapabilityDependenciesCheck,
|
||||||
|
domain.JobCapabilityDependenciesInstall,
|
||||||
|
domain.JobCapabilityLogsBackfill,
|
||||||
|
)
|
||||||
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||||
|
t.Fatalf("update endpoint fixture: %v", err)
|
||||||
|
}
|
||||||
|
session := createServiceUserAndLogin(t, svc, domain.User{
|
||||||
|
ID: "user-distribution-owner",
|
||||||
|
DisplayName: "Distribution Owner",
|
||||||
|
Email: "distribution-owner@example.test",
|
||||||
|
Roles: []string{"server-owner"},
|
||||||
|
PasswordHash: "secret-password",
|
||||||
|
})
|
||||||
|
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
|
||||||
|
ID: "server-distribution",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RunEndpointID: endpoint.ID,
|
||||||
|
Name: "Distribution Server",
|
||||||
|
State: domain.ServerInstanceStateReady,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create distribution server: %v", err)
|
||||||
|
}
|
||||||
|
return svc, session, instance
|
||||||
|
}
|
||||||
|
|
||||||
|
func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig {
|
||||||
|
t.Helper()
|
||||||
|
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read artifact content: %v", err)
|
||||||
|
}
|
||||||
|
var config generatedPackageConfig
|
||||||
|
if err := json.Unmarshal(content.Payload, &config); err != nil {
|
||||||
|
t.Fatalf("unmarshal generated config: %v", err)
|
||||||
|
}
|
||||||
|
if config.AuthKey == "" {
|
||||||
|
var packagePayload generatedClientManagerPackage
|
||||||
|
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
|
||||||
|
t.Fatalf("unmarshal generated client-manager package: %v", err)
|
||||||
|
}
|
||||||
|
config = packagePayload.Config
|
||||||
|
}
|
||||||
|
if config.AuthKey == "" || config.SecretRef == "" || config.KeyGeneration <= 0 {
|
||||||
|
t.Fatalf("generated package config is incomplete: %+v", config)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func readClientManagerPackage(t *testing.T, svc *CoreService, session string, artifactID string) generatedClientManagerPackage {
|
||||||
|
t.Helper()
|
||||||
|
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read client-manager package content: %v", err)
|
||||||
|
}
|
||||||
|
var packagePayload generatedClientManagerPackage
|
||||||
|
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
|
||||||
|
t.Fatalf("unmarshal generated client-manager package: %v", err)
|
||||||
|
}
|
||||||
|
return packagePayload
|
||||||
|
}
|
||||||
|
|
||||||
|
func readArtifactString(t *testing.T, svc *CoreService, session string, artifactID string) string {
|
||||||
|
t.Helper()
|
||||||
|
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read artifact content: %v", err)
|
||||||
|
}
|
||||||
|
return string(content.Payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
|
||||||
|
svc := newTestCoreService()
|
||||||
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
session := createServiceUserAndLogin(t, svc, domain.User{
|
||||||
|
ID: "user-distribution-denied",
|
||||||
|
DisplayName: "Distribution Denied",
|
||||||
|
Email: "distribution-denied@example.test",
|
||||||
|
Roles: []string{"server-owner"},
|
||||||
|
PasswordHash: "secret-password",
|
||||||
|
})
|
||||||
|
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
|
||||||
|
ID: "server-distribution-denied",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RunEndpointID: endpoint.ID,
|
||||||
|
Name: "Distribution Denied Server",
|
||||||
|
State: domain.ServerInstanceStateReady,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create denied server: %v", err)
|
||||||
|
}
|
||||||
|
_, err = svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
IdempotencyKey: "idem-run-denied",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrForbidden) {
|
||||||
|
t.Fatalf("expected plugin declaration denial, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,11 +66,13 @@ type Core interface {
|
|||||||
StopServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
StopServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||||
GetServerInstance(string) (domain.ServerInstance, error)
|
GetServerInstance(string) (domain.ServerInstance, error)
|
||||||
GetServerInstanceForSession(string, string) (domain.ServerInstance, error)
|
GetServerInstanceForSession(string, string) (domain.ServerInstance, error)
|
||||||
|
UpdateServerInstanceForSession(string, string, domain.ServerInstanceUpdate) (domain.ServerInstance, error)
|
||||||
ListServerInstances(domain.ServerInstanceFilter) ([]domain.ServerInstance, error)
|
ListServerInstances(domain.ServerInstanceFilter) ([]domain.ServerInstance, error)
|
||||||
ListServerInstancesForSession(string, domain.ServerInstanceFilter) ([]domain.ServerInstance, error)
|
ListServerInstancesForSession(string, domain.ServerInstanceFilter) ([]domain.ServerInstance, error)
|
||||||
ListServerAdministratorCandidates(string, string) ([]domain.User, error)
|
ListServerAdministratorCandidates(string, string) ([]domain.User, error)
|
||||||
AddServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
AddServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
||||||
RemoveServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
RemoveServerAdministrator(string, 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)
|
||||||
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
|
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
|
||||||
@@ -93,6 +95,16 @@ type Core interface {
|
|||||||
GetArtifactForSession(string, string) (domain.Artifact, error)
|
GetArtifactForSession(string, string) (domain.Artifact, error)
|
||||||
OpenArtifactDownloadForSession(string, domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error)
|
OpenArtifactDownloadForSession(string, domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error)
|
||||||
ReadArtifactContentForSession(string, domain.ArtifactContentRequest) (domain.ArtifactContent, error)
|
ReadArtifactContentForSession(string, domain.ArtifactContentRequest) (domain.ArtifactContent, error)
|
||||||
|
GetServerRuntimeActionsForSession(string, string) (domain.ServerRuntimeActions, error)
|
||||||
|
GenerateRunDistributionForSession(string, domain.RunDistributionGenerateRequest) (domain.RunDistribution, error)
|
||||||
|
GenerateClientManagerDistributionForSession(string, domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error)
|
||||||
|
OpenLatestRunDistributionDownloadForSession(string, string) (domain.ArtifactDownloadReference, error)
|
||||||
|
OpenLatestClientManagerDistributionDownloadForSession(string, string, string) (domain.ArtifactDownloadReference, error)
|
||||||
|
ResetComponentKeyForSession(string, domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error)
|
||||||
|
AuthenticateComponent(domain.ComponentAuthenticationRequest) (domain.ComponentAuthenticationResult, error)
|
||||||
|
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
|
||||||
|
QueueDependencyJobForSession(string, domain.DependencyJobRequest) (domain.Job, error)
|
||||||
|
QueueLogBackfillForSession(string, domain.LogBackfillRequest) (domain.Job, error)
|
||||||
OpenArtifactTransfer(domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error)
|
OpenArtifactTransfer(domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error)
|
||||||
UploadArtifactChunk(domain.ArtifactChunkUpload) (domain.ArtifactChunkUploadResult, error)
|
UploadArtifactChunk(domain.ArtifactChunkUpload) (domain.ArtifactChunkUploadResult, error)
|
||||||
QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery) (domain.ArtifactTransferStatusResult, error)
|
QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery) (domain.ArtifactTransferStatusResult, error)
|
||||||
@@ -121,7 +133,10 @@ type CoreService struct {
|
|||||||
logStore LogBodyStore
|
logStore LogBodyStore
|
||||||
artifactMu sync.Mutex
|
artifactMu sync.Mutex
|
||||||
artifactTransfers map[string]domain.ArtifactTransferSession
|
artifactTransfers map[string]domain.ArtifactTransferSession
|
||||||
|
artifactPayloads map[string][]byte
|
||||||
artifactTransferSeq uint64
|
artifactTransferSeq uint64
|
||||||
|
auditMu sync.Mutex
|
||||||
|
auditSeq uint64
|
||||||
aiProviderClient AIProviderClient
|
aiProviderClient AIProviderClient
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +166,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
|||||||
jobLeases: map[string]domain.RunJobLease{},
|
jobLeases: map[string]domain.RunJobLease{},
|
||||||
logStore: logStore,
|
logStore: logStore,
|
||||||
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
||||||
|
artifactPayloads: map[string][]byte{},
|
||||||
aiProviderClient: MockAIProviderClient{},
|
aiProviderClient: MockAIProviderClient{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,6 +539,7 @@ 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,
|
||||||
|
RemoteAccess: manifest.RemoteAccess,
|
||||||
Status: domain.GamePluginStatusInstalled,
|
Status: domain.GamePluginStatusInstalled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,6 +621,16 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma
|
|||||||
base = svc.executeBridgeLogsQuery(base, instance, request.Payload)
|
base = svc.executeBridgeLogsQuery(base, instance, request.Payload)
|
||||||
case domain.PluginBridgeActionFilesRequest:
|
case domain.PluginBridgeActionFilesRequest:
|
||||||
base = svc.executeBridgeFileRequest(sessionID, base, request)
|
base = svc.executeBridgeFileRequest(sessionID, base, request)
|
||||||
|
case domain.PluginBridgeActionRemoteAccessRequest:
|
||||||
|
base = svc.executeBridgeRemoteAccessRequest(base, plugin, instance, request.Payload)
|
||||||
|
case domain.PluginBridgeActionRunDistribution:
|
||||||
|
base = svc.executeBridgeRunDistribution(sessionID, base, request)
|
||||||
|
case domain.PluginBridgeActionDependenciesRequest:
|
||||||
|
base = svc.executeBridgeDependenciesRequest(base, plugin, instance, request.Payload)
|
||||||
|
case domain.PluginBridgeActionLogsBackfillRequest:
|
||||||
|
base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload)
|
||||||
|
case domain.PluginBridgeActionClientManager:
|
||||||
|
base = svc.executeBridgeClientManager(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:
|
||||||
@@ -815,6 +842,146 @@ func (svc *CoreService) executeBridgeFileRequest(sessionID string, base domain.P
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) executeBridgeRemoteAccessRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
|
||||||
|
capability := strings.TrimSpace(payload["capability"])
|
||||||
|
if capability == "" {
|
||||||
|
base.Status = "error"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "capability is required"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if !containsString(plugin.RequiredRunCapabilities, capability) {
|
||||||
|
base.Status = "denied"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "requested remote capability is not declared by plugin"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
job := domain.Job{
|
||||||
|
ID: jobIDFromParts("job-remote", base.RequestID, capability),
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
RunEndpointID: instance.RunEndpointID,
|
||||||
|
Capability: capability,
|
||||||
|
TargetKey: payload["targetKey"],
|
||||||
|
InputRef: payload["inputRef"],
|
||||||
|
IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID),
|
||||||
|
Progress: domain.JobProgress{Percent: 0, Message: "remote access job queued"},
|
||||||
|
}
|
||||||
|
created, err := svc.CreateJob(job)
|
||||||
|
if err != nil {
|
||||||
|
return bridgeExecutionError(base, err)
|
||||||
|
}
|
||||||
|
base.Status = "queued"
|
||||||
|
base.Result = map[string]string{
|
||||||
|
"jobId": created.ID,
|
||||||
|
"state": string(created.State),
|
||||||
|
"capability": created.Capability,
|
||||||
|
"targetKey": created.TargetKey,
|
||||||
|
"serverInstanceId": created.ServerInstanceID,
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
||||||
|
distribution, err := svc.GenerateRunDistributionForSession(sessionID, domain.RunDistributionGenerateRequest{
|
||||||
|
ServerInstanceID: request.ServerInstanceID,
|
||||||
|
TargetOS: defaultBridgeValue(request.Payload["targetOs"], "linux"),
|
||||||
|
TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"),
|
||||||
|
IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return bridgeExecutionError(base, err)
|
||||||
|
}
|
||||||
|
base.Status = "ok"
|
||||||
|
base.Result = map[string]string{
|
||||||
|
"distributionId": distribution.ID,
|
||||||
|
"artifactId": distribution.ArtifactID,
|
||||||
|
"checksum": distribution.Checksum,
|
||||||
|
"keyGeneration": strconv.Itoa(distribution.KeyGeneration),
|
||||||
|
"secretRef": distribution.SecretRef,
|
||||||
|
"status": string(distribution.Status),
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) executeBridgeClientManager(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
||||||
|
distribution, err := svc.GenerateClientManagerDistributionForSession(sessionID, domain.ClientManagerBuildRequest{
|
||||||
|
ServerInstanceID: request.ServerInstanceID,
|
||||||
|
ProfileKey: request.Payload["profileKey"],
|
||||||
|
TargetOS: defaultBridgeValue(request.Payload["targetOs"], "windows"),
|
||||||
|
TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"),
|
||||||
|
RepositoryURL: request.Payload["repositoryUrl"],
|
||||||
|
SourceRevision: request.Payload["sourceRevision"],
|
||||||
|
IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return bridgeExecutionError(base, err)
|
||||||
|
}
|
||||||
|
base.Status = "ok"
|
||||||
|
base.Result = map[string]string{
|
||||||
|
"distributionId": distribution.ID,
|
||||||
|
"buildJobId": distribution.BuildJobID,
|
||||||
|
"artifactId": distribution.ArtifactID,
|
||||||
|
"checksum": distribution.Checksum,
|
||||||
|
"keyGeneration": strconv.Itoa(distribution.KeyGeneration),
|
||||||
|
"secretRef": distribution.SecretRef,
|
||||||
|
"status": string(distribution.Status),
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) executeBridgeDependenciesRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
|
||||||
|
action := defaultBridgeValue(payload["action"], "check")
|
||||||
|
capability := domain.JobCapabilityDependenciesCheck
|
||||||
|
message := "dependency check queued"
|
||||||
|
if action == "install" {
|
||||||
|
capability = domain.JobCapabilityDependenciesInstall
|
||||||
|
message = "dependency install queued"
|
||||||
|
}
|
||||||
|
if !containsString(plugin.RequiredRunCapabilities, capability) {
|
||||||
|
base.Status = "denied"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "dependency capability is not declared by plugin"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
job, err := svc.CreateJob(domain.Job{
|
||||||
|
ID: jobIDFromParts("job-dependencies", base.RequestID, capability),
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
RunEndpointID: instance.RunEndpointID,
|
||||||
|
Capability: capability,
|
||||||
|
TargetKey: defaultBridgeValue(payload["probeKey"], "dependencies/default"),
|
||||||
|
InputRef: payload["inputRef"],
|
||||||
|
IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID),
|
||||||
|
Progress: domain.JobProgress{Percent: 0, Message: message},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return bridgeExecutionError(base, err)
|
||||||
|
}
|
||||||
|
base.Status = "queued"
|
||||||
|
base.Result = map[string]string{"jobId": job.ID, "state": string(job.State), "capability": job.Capability, "targetKey": job.TargetKey}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) executeBridgeLogsBackfillRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
|
||||||
|
if !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityLogsBackfill) {
|
||||||
|
base.Status = "denied"
|
||||||
|
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "log backfill capability is not declared by plugin"}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
job, err := svc.CreateJob(domain.Job{
|
||||||
|
ID: jobIDFromParts("job-logs-backfill", base.RequestID, payload["sourceKey"]),
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
RunEndpointID: instance.RunEndpointID,
|
||||||
|
Capability: domain.JobCapabilityLogsBackfill,
|
||||||
|
TargetKey: defaultBridgeValue(payload["sourceKey"], "logs/default"),
|
||||||
|
InputRef: payload["checkpointRef"],
|
||||||
|
IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID),
|
||||||
|
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return bridgeExecutionError(base, err)
|
||||||
|
}
|
||||||
|
base.Status = "queued"
|
||||||
|
base.Result = map[string]string{"jobId": job.ID, "state": string(job.State), "capability": job.Capability, "sourceKey": job.TargetKey}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
func bridgeExecutionError(base domain.PluginBridgeExecuteResponse, err error) domain.PluginBridgeExecuteResponse {
|
func bridgeExecutionError(base domain.PluginBridgeExecuteResponse, err error) domain.PluginBridgeExecuteResponse {
|
||||||
base.Status = "error"
|
base.Status = "error"
|
||||||
base.Error = &domain.PluginBridgeSafeError{Code: "execution_failed", Message: safeBridgeReason(err.Error())}
|
base.Error = &domain.PluginBridgeSafeError{Code: "execution_failed", Message: safeBridgeReason(err.Error())}
|
||||||
@@ -855,6 +1022,11 @@ func pluginPermissionsFromManifest(permissions []string) domain.PluginPermission
|
|||||||
aggregate.Jobs = true
|
aggregate.Jobs = true
|
||||||
case "server.artifacts.read", "server.artifacts.write":
|
case "server.artifacts.read", "server.artifacts.write":
|
||||||
aggregate.Artifacts = true
|
aggregate.Artifacts = true
|
||||||
|
case "server.remote.access":
|
||||||
|
aggregate.RemoteAccess = true
|
||||||
|
case "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage":
|
||||||
|
aggregate.Jobs = true
|
||||||
|
aggregate.Artifacts = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return aggregate
|
return aggregate
|
||||||
@@ -962,6 +1134,7 @@ 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,
|
||||||
|
RemoteAccess: plugin.RemoteAccess,
|
||||||
ValidationViolations: plugin.ValidationViolations,
|
ValidationViolations: plugin.ValidationViolations,
|
||||||
Status: plugin.Status,
|
Status: plugin.Status,
|
||||||
Source: "platform-registry",
|
Source: "platform-registry",
|
||||||
@@ -1077,6 +1250,37 @@ func (svc *CoreService) GetServerInstanceForSession(sessionID string, id string)
|
|||||||
return domain.CopyServerInstance(instance), nil
|
return domain.CopyServerInstance(instance), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) UpdateServerInstanceForSession(sessionID string, id string, update domain.ServerInstanceUpdate) (domain.ServerInstance, error) {
|
||||||
|
if err := validator.ValidateServerInstanceUpdate(update); err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID {
|
||||||
|
return domain.ServerInstance{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if instance.State == domain.ServerInstanceStateDeleted {
|
||||||
|
return domain.ServerInstance{}, validationError("deleted server instances cannot be edited")
|
||||||
|
}
|
||||||
|
if update.Name != nil {
|
||||||
|
instance.Name = *update.Name
|
||||||
|
}
|
||||||
|
instance.UpdatedAt = svc.now()
|
||||||
|
if err := validator.ValidateStoredServerInstance(instance); err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyServerInstance(instance), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *CoreService) ListServerInstances(filter domain.ServerInstanceFilter) ([]domain.ServerInstance, error) {
|
func (svc *CoreService) ListServerInstances(filter domain.ServerInstanceFilter) ([]domain.ServerInstance, error) {
|
||||||
return svc.store.ServerInstances().List(filter)
|
return svc.store.ServerInstances().List(filter)
|
||||||
}
|
}
|
||||||
@@ -1487,6 +1691,35 @@ func (svc *CoreService) RemoveServerAdministrator(sessionID string, serverInstan
|
|||||||
return domain.CopyServerInstance(instance), nil
|
return domain.CopyServerInstance(instance), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ArchiveServerInstanceForSession(sessionID string, serverInstanceID string) (domain.ServerInstance, error) {
|
||||||
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID {
|
||||||
|
return domain.ServerInstance{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateInstalling {
|
||||||
|
return domain.ServerInstance{}, validationError("running or installing server instances must be stopped before archive")
|
||||||
|
}
|
||||||
|
if instance.State == domain.ServerInstanceStateDeleted {
|
||||||
|
return domain.CopyServerInstance(instance), nil
|
||||||
|
}
|
||||||
|
instance.State = domain.ServerInstanceStateDeleted
|
||||||
|
instance.UpdatedAt = svc.now()
|
||||||
|
if err := validator.ValidateStoredServerInstance(instance); err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||||
|
return domain.ServerInstance{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyServerInstance(instance), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
|
func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
|
||||||
if job.State == "" {
|
if job.State == "" {
|
||||||
job.State = domain.JobStateQueued
|
job.State = domain.JobStateQueued
|
||||||
@@ -1522,7 +1755,11 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Job{}, fmt.Errorf("get server instance dependency: %w", err)
|
return domain.Job{}, fmt.Errorf("get server instance dependency: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateJobServerTarget(job, instance); err != nil {
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Job{}, fmt.Errorf("get server plugin dependency: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateJobServerTarget(job, instance, plugin); err != nil {
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1632,13 +1869,19 @@ func validateRunnableEndpoint(endpoint domain.RunEndpoint, capability string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateJobServerTarget(job domain.Job, instance domain.ServerInstance) error {
|
func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plugin domain.GamePlugin) error {
|
||||||
if instance.State == domain.ServerInstanceStateDeleted {
|
if instance.State == domain.ServerInstanceStateDeleted {
|
||||||
return validationError("server instance must not be deleted")
|
return validationError("server instance must not be deleted")
|
||||||
}
|
}
|
||||||
if instance.RunEndpointID != job.RunEndpointID {
|
if instance.RunEndpointID != job.RunEndpointID {
|
||||||
return validationError("job runEndpointId must match server instance")
|
return validationError("job runEndpointId must match server instance")
|
||||||
}
|
}
|
||||||
|
if plugin.ID != instance.PluginID {
|
||||||
|
return validationError("job plugin must match server instance")
|
||||||
|
}
|
||||||
|
if !containsString(plugin.RequiredRunCapabilities, job.Capability) {
|
||||||
|
return validationError("plugin missing required capability: " + job.Capability)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -876,6 +876,105 @@ func TestCoreServiceAuthorizesPluginBridgeActions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceRemoteAccessRequiresPluginDeclaration(t *testing.T) {
|
||||||
|
svc := newTestCoreService()
|
||||||
|
registration := validPluginManifestRegistration()
|
||||||
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities,
|
||||||
|
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||||
|
domain.JobCapabilityRemoteRunLogsTransfer,
|
||||||
|
domain.JobCapabilityRemoteRunRCONCommand,
|
||||||
|
)
|
||||||
|
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.remote.access")
|
||||||
|
registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||||
|
registration.Manifest.Pages = append(registration.Manifest.Pages, domain.GamePluginPage{
|
||||||
|
Key: "remote",
|
||||||
|
Title: "Remote",
|
||||||
|
Path: "/remote",
|
||||||
|
Permissions: []string{"server.remote.access"},
|
||||||
|
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
|
||||||
|
})
|
||||||
|
registration.Manifest.RemoteAccess = domain.GamePluginRemoteAccess{
|
||||||
|
Methods: []string{"run"},
|
||||||
|
RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand},
|
||||||
|
DatabaseEngines: []string{"sqlite"},
|
||||||
|
RCON: true,
|
||||||
|
LogTransfer: true,
|
||||||
|
}
|
||||||
|
plugin, err := svc.RegisterGamePluginManifest(registration)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register remote manifest: %v", err)
|
||||||
|
}
|
||||||
|
if !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || plugin.RemoteAccess.DatabaseEngines[0] != "sqlite" {
|
||||||
|
t.Fatalf("expected remote access metadata from manifest, got %+v", plugin)
|
||||||
|
}
|
||||||
|
marketplace, err := svc.GetMarketplacePlugin(plugin.ID)
|
||||||
|
if err != nil || !marketplace.RemoteAccess.LogTransfer || marketplace.RemoteAccess.Methods[0] != "run" {
|
||||||
|
t.Fatalf("expected marketplace remote access projection, got %+v err=%v", marketplace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{
|
||||||
|
ID: "run-remote",
|
||||||
|
DisplayName: "Remote Run",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Capabilities: append([]string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}, plugin.RemoteAccess.RunCapabilities...),
|
||||||
|
Capacity: domain.RunCapacity{MaxJobs: 2},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create remote endpoint: %v", err)
|
||||||
|
}
|
||||||
|
ownerSession := 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(ownerSession, domain.ServerInstance{
|
||||||
|
ID: "server-remote",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RunEndpointID: endpoint.ID,
|
||||||
|
Name: "Remote Server",
|
||||||
|
State: domain.ServerInstanceStateRunning,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create remote server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
queued, err := svc.ExecutePluginBridgeAction(ownerSession, domain.PluginBridgeExecuteRequest{
|
||||||
|
RequestID: "remote-rcon-1",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RouteKey: "remote",
|
||||||
|
ServerInstanceID: instance.ID,
|
||||||
|
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||||
|
Payload: map[string]string{
|
||||||
|
"capability": domain.JobCapabilityRemoteRunRCONCommand,
|
||||||
|
"targetKey": "rcon/command",
|
||||||
|
"inputRef": "input://server-remote/rcon/command/1",
|
||||||
|
"idempotencyKey": "idem-remote-rcon",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute remote bridge action: %v", err)
|
||||||
|
}
|
||||||
|
if queued.Status != "queued" || queued.Result["capability"] != domain.JobCapabilityRemoteRunRCONCommand {
|
||||||
|
t.Fatalf("expected queued remote bridge job, got %+v", queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
plainPlugin, plainEndpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
plainEndpoint.Capabilities = append(plainEndpoint.Capabilities, domain.JobCapabilityRemoteRunRCONCommand)
|
||||||
|
if err := svc.store.RunEndpoints().Update(plainEndpoint); err != nil {
|
||||||
|
t.Fatalf("extend plain endpoint: %v", err)
|
||||||
|
}
|
||||||
|
plainInstance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-plain", PluginID: plainPlugin.ID, RunEndpointID: plainEndpoint.ID, Name: "Plain Server"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create plain server: %v", err)
|
||||||
|
}
|
||||||
|
_, err = svc.CreateJob(domain.Job{ID: "job-remote-denied", ServerInstanceID: plainInstance.ID, RunEndpointID: plainEndpoint.ID, Capability: domain.JobCapabilityRemoteRunRCONCommand, TargetKey: "rcon/command", InputRef: "input://plain/rcon/command/1", IdempotencyKey: "idem-denied"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "plugin missing required capability") {
|
||||||
|
t.Fatalf("expected undeclared remote job denial, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
|
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
registration := validPluginManifestRegistration()
|
registration := validPluginManifestRegistration()
|
||||||
@@ -1034,7 +1133,7 @@ func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlug
|
|||||||
ServerType: "scum",
|
ServerType: "scum",
|
||||||
ManifestRef: "artifact://manifests/server.scum/1.0.0",
|
ManifestRef: "artifact://manifests/server.scum/1.0.0",
|
||||||
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
|
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
|
||||||
RequiredRunCapabilities: []string{"process.install", "process.start", "process.stop", "logs.read"},
|
RequiredRunCapabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write"},
|
||||||
DeclaredPermissions: []string{"server.files.read", "server.files.write"},
|
DeclaredPermissions: []string{"server.files.read", "server.files.write"},
|
||||||
LifecycleActions: domain.PluginLifecycleActions{
|
LifecycleActions: domain.PluginLifecycleActions{
|
||||||
Install: "actions/install.json",
|
Install: "actions/install.json",
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ func ValidateRunControlHello(hello domain.RunControlHello) error {
|
|||||||
violations = appendRequired(violations, "displayName", hello.DisplayName)
|
violations = appendRequired(violations, "displayName", hello.DisplayName)
|
||||||
violations = appendRequired(violations, "version", hello.Version)
|
violations = appendRequired(violations, "version", hello.Version)
|
||||||
violations = appendRequired(violations, "capabilityReport.fingerprint", hello.CapabilityReport.Fingerprint)
|
violations = appendRequired(violations, "capabilityReport.fingerprint", hello.CapabilityReport.Fingerprint)
|
||||||
|
if hello.ServerInstanceID != "" || hello.PluginID != "" || hello.ComponentKind != "" || hello.ComponentKey != "" || hello.KeyGeneration != 0 {
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", hello.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "pluginId", hello.PluginID)
|
||||||
|
if hello.ComponentKind != domain.DistributionComponentRun && hello.ComponentKind != domain.DistributionComponentClientManager {
|
||||||
|
violations = append(violations, "componentKind is invalid")
|
||||||
|
}
|
||||||
|
if hello.KeyGeneration <= 0 {
|
||||||
|
violations = append(violations, "keyGeneration must be positive")
|
||||||
|
}
|
||||||
|
}
|
||||||
if !validRunControlStatus(hello.Status) {
|
if !validRunControlStatus(hello.Status) {
|
||||||
violations = append(violations, "status is invalid")
|
violations = append(violations, "status is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxDistributionMessageLength = 256
|
||||||
|
|
||||||
|
func ValidateRuntimeBinding(binding domain.RuntimeBinding) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", binding.ID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", binding.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "pluginId", binding.PluginID)
|
||||||
|
violations = appendRequired(violations, "profileKey", binding.ProfileKey)
|
||||||
|
violations = appendRequired(violations, "mode", binding.Mode)
|
||||||
|
if !validRuntimeBindingStatus(binding.Status) {
|
||||||
|
violations = append(violations, "status is invalid")
|
||||||
|
}
|
||||||
|
for key, value := range binding.Bindings {
|
||||||
|
if !validDistributionLogicalKey(key) {
|
||||||
|
violations = append(violations, "bindings key is invalid")
|
||||||
|
}
|
||||||
|
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "://") && !strings.HasPrefix(value, "secret://") {
|
||||||
|
violations = append(violations, "bindings."+key+" must use safe logical or secret refs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, key := range binding.MissingKeys {
|
||||||
|
if !validDistributionLogicalKey(key) {
|
||||||
|
violations = append(violations, fmt.Sprintf("missingKeys[%d] is invalid", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if binding.CreatedAt.IsZero() {
|
||||||
|
violations = append(violations, "createdAt is required")
|
||||||
|
}
|
||||||
|
if binding.UpdatedAt.IsZero() {
|
||||||
|
violations = append(violations, "updatedAt is required")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateEncryptedComponentKey(key domain.EncryptedComponentKey) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", key.ID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", key.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "encryptedKey", key.EncryptedKey)
|
||||||
|
violations = appendRequired(violations, "keyHash", key.KeyHash)
|
||||||
|
violations = appendRequired(violations, "fingerprint", key.Fingerprint)
|
||||||
|
violations = appendRequired(violations, "secretRef", key.SecretRef)
|
||||||
|
if !validDistributionComponentKind(key.ComponentKind) {
|
||||||
|
violations = append(violations, "componentKind is invalid")
|
||||||
|
}
|
||||||
|
if key.ComponentKind == domain.DistributionComponentClientManager && strings.TrimSpace(key.ComponentKey) == "" {
|
||||||
|
violations = append(violations, "componentKey is required for client-manager keys")
|
||||||
|
}
|
||||||
|
if key.ComponentKey != "" && !validDistributionLogicalKey(key.ComponentKey) {
|
||||||
|
violations = append(violations, "componentKey is invalid")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(key.EncryptedKey, "enc:v1:") {
|
||||||
|
violations = append(violations, "encryptedKey must be encrypted")
|
||||||
|
}
|
||||||
|
if key.KeyHash != "" && !validSHA256Checksum(key.KeyHash) {
|
||||||
|
violations = append(violations, "keyHash must be sha256:<hex>")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(key.SecretRef, "secret://runtime-keys/") {
|
||||||
|
violations = append(violations, "secretRef must be a runtime key secret ref")
|
||||||
|
}
|
||||||
|
if key.Generation <= 0 {
|
||||||
|
violations = append(violations, "generation must be positive")
|
||||||
|
}
|
||||||
|
if !validComponentKeyStatus(key.Status) {
|
||||||
|
violations = append(violations, "status is invalid")
|
||||||
|
}
|
||||||
|
if key.CreatedAt.IsZero() {
|
||||||
|
violations = append(violations, "createdAt is required")
|
||||||
|
}
|
||||||
|
if key.UpdatedAt.IsZero() {
|
||||||
|
violations = append(violations, "updatedAt is required")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateRunDistribution(distribution domain.RunDistribution) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", distribution.ID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", distribution.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "pluginId", distribution.PluginID)
|
||||||
|
violations = appendRequired(violations, "runEndpointId", distribution.RunEndpointID)
|
||||||
|
violations = appendRequired(violations, "targetOs", distribution.TargetOS)
|
||||||
|
violations = appendRequired(violations, "targetArch", distribution.TargetArch)
|
||||||
|
violations = appendRequired(violations, "packageFormat", distribution.PackageFormat)
|
||||||
|
violations = appendRequired(violations, "artifactId", distribution.ArtifactID)
|
||||||
|
violations = appendRequired(violations, "checksum", distribution.Checksum)
|
||||||
|
violations = appendRequired(violations, "secretRef", distribution.SecretRef)
|
||||||
|
violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch)
|
||||||
|
violations = appendDistributionStatusViolations(violations, distribution.Status)
|
||||||
|
if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) {
|
||||||
|
violations = append(violations, "checksum must be sha256:<hex>")
|
||||||
|
}
|
||||||
|
if distribution.KeyGeneration <= 0 {
|
||||||
|
violations = append(violations, "keyGeneration must be positive")
|
||||||
|
}
|
||||||
|
if distribution.PackageFormat != "zip" && distribution.PackageFormat != "tar.gz" {
|
||||||
|
violations = append(violations, "packageFormat is invalid")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(distribution.SecretRef, "secret://runtime-keys/") {
|
||||||
|
violations = append(violations, "secretRef must be redacted runtime key ref")
|
||||||
|
}
|
||||||
|
if distribution.CreatedAt.IsZero() {
|
||||||
|
violations = append(violations, "createdAt is required")
|
||||||
|
}
|
||||||
|
if distribution.UpdatedAt.IsZero() {
|
||||||
|
violations = append(violations, "updatedAt is required")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateClientManagerDistribution(distribution domain.ClientManagerDistribution) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", distribution.ID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", distribution.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "pluginId", distribution.PluginID)
|
||||||
|
violations = appendRequired(violations, "profileKey", distribution.ProfileKey)
|
||||||
|
violations = appendRequired(violations, "repositoryUrl", distribution.RepositoryURL)
|
||||||
|
violations = appendRequired(violations, "sourceRevision", distribution.SourceRevision)
|
||||||
|
violations = appendRequired(violations, "buildJobId", distribution.BuildJobID)
|
||||||
|
violations = appendRequired(violations, "artifactId", distribution.ArtifactID)
|
||||||
|
violations = appendRequired(violations, "checksum", distribution.Checksum)
|
||||||
|
violations = appendRequired(violations, "secretRef", distribution.SecretRef)
|
||||||
|
violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch)
|
||||||
|
violations = appendDistributionStatusViolations(violations, distribution.Status)
|
||||||
|
if !validDistributionLogicalKey(distribution.ProfileKey) {
|
||||||
|
violations = append(violations, "profileKey is invalid")
|
||||||
|
}
|
||||||
|
if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) {
|
||||||
|
violations = append(violations, "checksum must be sha256:<hex>")
|
||||||
|
}
|
||||||
|
if distribution.KeyGeneration <= 0 {
|
||||||
|
violations = append(violations, "keyGeneration must be positive")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateRepositoryURL("repositoryUrl", distribution.RepositoryURL)...)
|
||||||
|
if !strings.HasPrefix(distribution.SecretRef, "secret://runtime-keys/") {
|
||||||
|
violations = append(violations, "secretRef must be redacted runtime key ref")
|
||||||
|
}
|
||||||
|
if distribution.CreatedAt.IsZero() {
|
||||||
|
violations = append(violations, "createdAt is required")
|
||||||
|
}
|
||||||
|
if distribution.UpdatedAt.IsZero() {
|
||||||
|
violations = append(violations, "updatedAt is required")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDependencyStatus(status domain.DependencyStatus) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", status.ID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", status.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "pluginId", status.PluginID)
|
||||||
|
violations = appendRequired(violations, "probeKey", status.ProbeKey)
|
||||||
|
if !validDistributionLogicalKey(status.ProbeKey) {
|
||||||
|
violations = append(violations, "probeKey is invalid")
|
||||||
|
}
|
||||||
|
if status.TargetOS != "" || status.TargetArch != "" {
|
||||||
|
violations = appendDistributionTargetViolations(violations, status.TargetOS, status.TargetArch)
|
||||||
|
}
|
||||||
|
if !validDependencyState(status.State) {
|
||||||
|
violations = append(violations, "state is invalid")
|
||||||
|
}
|
||||||
|
if status.InstallPlanKey != "" && !validDistributionLogicalKey(status.InstallPlanKey) {
|
||||||
|
violations = append(violations, "installPlanKey is invalid")
|
||||||
|
}
|
||||||
|
if len(status.Message) > maxDistributionMessageLength || containsUnsafeRuntimeSecret(status.Message) || looksLikeRawHostPath(status.Message) {
|
||||||
|
violations = append(violations, "message is unsafe or too long")
|
||||||
|
}
|
||||||
|
if status.CheckedAt.IsZero() {
|
||||||
|
violations = append(violations, "checkedAt is required")
|
||||||
|
}
|
||||||
|
if status.UpdatedAt.IsZero() {
|
||||||
|
violations = append(violations, "updatedAt is required")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateClientManagerBuildJob(job domain.ClientManagerBuildJob) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", job.ID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", job.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "pluginId", job.PluginID)
|
||||||
|
violations = appendRequired(violations, "profileKey", job.ProfileKey)
|
||||||
|
violations = appendRequired(violations, "repositoryUrl", job.RepositoryURL)
|
||||||
|
violations = appendRequired(violations, "sourceRevision", job.SourceRevision)
|
||||||
|
violations = appendDistributionTargetViolations(violations, job.TargetOS, job.TargetArch)
|
||||||
|
if !validDistributionLogicalKey(job.ProfileKey) {
|
||||||
|
violations = append(violations, "profileKey is invalid")
|
||||||
|
}
|
||||||
|
if job.Checksum != "" && !validSHA256Checksum(job.Checksum) {
|
||||||
|
violations = append(violations, "checksum must be sha256:<hex>")
|
||||||
|
}
|
||||||
|
if job.LogsRef != "" && !validScopedInputRef(job.LogsRef) {
|
||||||
|
violations = append(violations, "logsRef is not allowed")
|
||||||
|
}
|
||||||
|
if job.KeyGeneration < 0 {
|
||||||
|
violations = append(violations, "keyGeneration must not be negative")
|
||||||
|
}
|
||||||
|
if !validDistributionJobStatus(job.Status) {
|
||||||
|
violations = append(violations, "status is invalid")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateRepositoryURL("repositoryUrl", job.RepositoryURL)...)
|
||||||
|
if job.CreatedAt.IsZero() {
|
||||||
|
violations = append(violations, "createdAt is required")
|
||||||
|
}
|
||||||
|
if job.UpdatedAt.IsZero() {
|
||||||
|
violations = append(violations, "updatedAt is required")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateRunUpdateJob(job domain.RunUpdateJob) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "id", job.ID)
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", job.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "runEndpointId", job.RunEndpointID)
|
||||||
|
violations = appendRequired(violations, "artifactId", job.ArtifactID)
|
||||||
|
violations = appendRequired(violations, "checksum", job.Checksum)
|
||||||
|
violations = appendRequired(violations, "idempotencyKey", job.IdempotencyKey)
|
||||||
|
if job.Checksum != "" && !validSHA256Checksum(job.Checksum) {
|
||||||
|
violations = append(violations, "checksum must be sha256:<hex>")
|
||||||
|
}
|
||||||
|
if !validDistributionJobStatus(job.Status) {
|
||||||
|
violations = append(violations, "status is invalid")
|
||||||
|
}
|
||||||
|
if containsUnsafeRuntimeSecret(job.IdempotencyKey) || looksLikeRawHostPath(job.IdempotencyKey) {
|
||||||
|
violations = append(violations, "idempotencyKey is unsafe")
|
||||||
|
}
|
||||||
|
if job.CreatedAt.IsZero() {
|
||||||
|
violations = append(violations, "createdAt is required")
|
||||||
|
}
|
||||||
|
if job.UpdatedAt.IsZero() {
|
||||||
|
violations = append(violations, "updatedAt is required")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateRunDistributionGenerateRequest(request domain.RunDistributionGenerateRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "targetOs", request.TargetOS)
|
||||||
|
violations = appendRequired(violations, "targetArch", request.TargetArch)
|
||||||
|
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||||
|
violations = appendDistributionTargetViolations(violations, request.TargetOS, request.TargetArch)
|
||||||
|
if containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey) {
|
||||||
|
violations = append(violations, "idempotencyKey is unsafe")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateClientManagerBuildRequest(request domain.ClientManagerBuildRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "profileKey", request.ProfileKey)
|
||||||
|
violations = appendRequired(violations, "targetOs", request.TargetOS)
|
||||||
|
violations = appendRequired(violations, "targetArch", request.TargetArch)
|
||||||
|
violations = appendRequired(violations, "repositoryUrl", request.RepositoryURL)
|
||||||
|
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||||
|
violations = appendDistributionTargetViolations(violations, request.TargetOS, request.TargetArch)
|
||||||
|
if !validDistributionLogicalKey(request.ProfileKey) {
|
||||||
|
violations = append(violations, "profileKey is invalid")
|
||||||
|
}
|
||||||
|
if request.SourceRevision != "" && !validDistributionLogicalKey(request.SourceRevision) {
|
||||||
|
violations = append(violations, "sourceRevision is invalid")
|
||||||
|
}
|
||||||
|
if containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey) {
|
||||||
|
violations = append(violations, "idempotencyKey is unsafe")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateRepositoryURL("repositoryUrl", request.RepositoryURL)...)
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateComponentKeyResetRequest(request domain.ComponentKeyResetRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||||
|
if !validDistributionComponentKind(request.ComponentKind) {
|
||||||
|
violations = append(violations, "componentKind is invalid")
|
||||||
|
}
|
||||||
|
if request.ComponentKind == domain.DistributionComponentClientManager && strings.TrimSpace(request.ComponentKey) == "" {
|
||||||
|
violations = append(violations, "componentKey is required for client-manager")
|
||||||
|
}
|
||||||
|
if request.ComponentKey != "" && !validDistributionLogicalKey(request.ComponentKey) {
|
||||||
|
violations = append(violations, "componentKey is invalid")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateComponentAuthenticationRequest(request domain.ComponentAuthenticationRequest) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||||
|
violations = appendRequired(violations, "key", request.Key)
|
||||||
|
if !validDistributionComponentKind(request.ComponentKind) {
|
||||||
|
violations = append(violations, "componentKind is invalid")
|
||||||
|
}
|
||||||
|
if request.ComponentKind == domain.DistributionComponentClientManager && strings.TrimSpace(request.ComponentKey) == "" {
|
||||||
|
violations = append(violations, "componentKey is required for client-manager")
|
||||||
|
}
|
||||||
|
if request.ComponentKey != "" && !validDistributionLogicalKey(request.ComponentKey) {
|
||||||
|
violations = append(violations, "componentKey is invalid")
|
||||||
|
}
|
||||||
|
if request.Generation <= 0 {
|
||||||
|
violations = append(violations, "generation must be positive")
|
||||||
|
}
|
||||||
|
if len(request.Key) > 256 || looksLikeRawHostPath(request.Key) || strings.Contains(strings.ToLower(request.Key), "://") {
|
||||||
|
violations = append(violations, "key is unsafe")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendDistributionTargetViolations(violations []string, targetOS string, targetArch string) []string {
|
||||||
|
if !validDistributionTargetOS(targetOS) {
|
||||||
|
violations = append(violations, "targetOs is invalid")
|
||||||
|
}
|
||||||
|
if !validDistributionTargetArch(targetArch) {
|
||||||
|
violations = append(violations, "targetArch is invalid")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendDistributionStatusViolations(violations []string, status domain.DistributionStatus) []string {
|
||||||
|
if !validDistributionStatus(status) {
|
||||||
|
return append(violations, "status is invalid")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRepositoryURL(field string, value string) []string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lowered := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if !strings.HasPrefix(lowered, "https://") || !strings.HasSuffix(lowered, ".git") {
|
||||||
|
return []string{field + " must be an HTTPS git repository URL"}
|
||||||
|
}
|
||||||
|
for _, reason := range unsafePluginStringReasons(value) {
|
||||||
|
return []string{field + ": " + reason}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDistributionComponentKind(kind domain.DistributionComponentKind) bool {
|
||||||
|
switch kind {
|
||||||
|
case domain.DistributionComponentRun, domain.DistributionComponentClientManager:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validComponentKeyStatus(status domain.ComponentKeyStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case domain.ComponentKeyStatusActive, domain.ComponentKeyStatusRevoked:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDistributionStatus(status domain.DistributionStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case domain.DistributionStatusAvailable, domain.DistributionStatusRevoked, domain.DistributionStatusBuilding, domain.DistributionStatusFailed:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRuntimeBindingStatus(status domain.RuntimeBindingStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case domain.RuntimeBindingStatusComplete, domain.RuntimeBindingStatusIncomplete:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDependencyState(state domain.DependencyState) bool {
|
||||||
|
switch state {
|
||||||
|
case domain.DependencyStateUnknown, domain.DependencyStatePresent, domain.DependencyStateMissing, domain.DependencyStateInstalling, domain.DependencyStateFailed:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDistributionJobStatus(status domain.DistributionJobStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case domain.DistributionJobStatusQueued, domain.DistributionJobStatusRunning, domain.DistributionJobStatusSucceeded, domain.DistributionJobStatusFailed, domain.DistributionJobStatusDenied:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDistributionTargetOS(targetOS string) bool {
|
||||||
|
switch targetOS {
|
||||||
|
case "linux", "windows", "darwin":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDistributionTargetArch(targetArch string) bool {
|
||||||
|
switch targetArch {
|
||||||
|
case "amd64", "arm64":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDistributionLogicalKey(value string) bool {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" || trimmed != value || len([]rune(value)) > 96 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, char := range value {
|
||||||
|
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !strings.Contains(value, "..") && !strings.Contains(value, "://") && !looksLikeRawHostPath(value) && !containsUnsafeRuntimeSecret(value)
|
||||||
|
}
|
||||||
@@ -145,6 +145,7 @@ 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, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...)
|
||||||
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
@@ -196,6 +197,7 @@ 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, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
||||||
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
@@ -348,6 +350,7 @@ func validatePluginMarketplacePlugin(prefix string, plugin domain.PluginMarketpl
|
|||||||
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
||||||
violations = append(violations, duplicateViolations(prefix+".tags", plugin.Tags)...)
|
violations = append(violations, duplicateViolations(prefix+".tags", plugin.Tags)...)
|
||||||
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
||||||
|
violations = append(violations, validateRemoteAccess(prefix+".remoteAccess", plugin.RemoteAccess, plugin.Capabilities)...)
|
||||||
violations = append(violations, validateSafePluginStrings(prefix, marketplacePluginSafeStrings(plugin))...)
|
violations = append(violations, validateSafePluginStrings(prefix, marketplacePluginSafeStrings(plugin))...)
|
||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
@@ -401,6 +404,14 @@ func AuthorizePluginBridgeAction(plugin domain.GamePlugin, request domain.Plugin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateServerInstance(instance domain.ServerInstance) error {
|
func ValidateServerInstance(instance domain.ServerInstance) error {
|
||||||
|
return validateServerInstance(instance, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateStoredServerInstance(instance domain.ServerInstance) error {
|
||||||
|
return validateServerInstance(instance, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateServerInstance(instance domain.ServerInstance, allowDeleted bool) error {
|
||||||
var violations []string
|
var violations []string
|
||||||
violations = appendRequired(violations, "id", instance.ID)
|
violations = appendRequired(violations, "id", instance.ID)
|
||||||
violations = appendRequired(violations, "pluginId", instance.PluginID)
|
violations = appendRequired(violations, "pluginId", instance.PluginID)
|
||||||
@@ -425,7 +436,7 @@ func ValidateServerInstance(instance domain.ServerInstance) error {
|
|||||||
if !validServerInstanceState(instance.State) {
|
if !validServerInstanceState(instance.State) {
|
||||||
violations = append(violations, "state is invalid")
|
violations = append(violations, "state is invalid")
|
||||||
}
|
}
|
||||||
if instance.State == domain.ServerInstanceStateDeleted {
|
if instance.State == domain.ServerInstanceStateDeleted && !allowDeleted {
|
||||||
violations = append(violations, "state must not be deleted on create")
|
violations = append(violations, "state must not be deleted on create")
|
||||||
}
|
}
|
||||||
if instance.ConfigVersion < 0 {
|
if instance.ConfigVersion < 0 {
|
||||||
@@ -434,6 +445,20 @@ func ValidateServerInstance(instance domain.ServerInstance) error {
|
|||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateServerInstanceUpdate(update domain.ServerInstanceUpdate) error {
|
||||||
|
var violations []string
|
||||||
|
if update.Name != nil {
|
||||||
|
name := strings.TrimSpace(*update.Name)
|
||||||
|
if name == "" {
|
||||||
|
violations = append(violations, "name is required")
|
||||||
|
}
|
||||||
|
if name != *update.Name {
|
||||||
|
violations = append(violations, "name must not have surrounding whitespace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateServerInstanceDependencies(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint) error {
|
func ValidateServerInstanceDependencies(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint) error {
|
||||||
var violations []string
|
var violations []string
|
||||||
if plugin.ID == "" {
|
if plugin.ID == "" {
|
||||||
@@ -698,6 +723,17 @@ func ValidateJob(job domain.Job) error {
|
|||||||
violations = append(violations, "inputRef is required for scoped write jobs")
|
violations = append(violations, "inputRef is required for scoped write jobs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if isRemoteRunCapability(job.Capability) {
|
||||||
|
if job.ServerInstanceID == "" {
|
||||||
|
violations = append(violations, "serverInstanceId is required for remote access jobs")
|
||||||
|
}
|
||||||
|
if remoteCapabilityRequiresTargetKey(job.Capability) && job.TargetKey == "" {
|
||||||
|
violations = append(violations, "targetKey is required for remote access jobs")
|
||||||
|
}
|
||||||
|
if remoteCapabilityRequiresInputRef(job.Capability) && job.InputRef == "" {
|
||||||
|
violations = append(violations, "inputRef is required for remote access jobs")
|
||||||
|
}
|
||||||
|
}
|
||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,6 +913,63 @@ func validateAIPurposes(purposes []string) []string {
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRemoteAccess(field string, remote domain.GamePluginRemoteAccess, declaredCapabilities []string) []string {
|
||||||
|
var violations []string
|
||||||
|
if len(remote.Methods) == 0 && len(remote.RunCapabilities) == 0 && len(remote.DatabaseEngines) == 0 && !remote.RCON && !remote.LogTransfer {
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
if len(remote.Methods) == 0 {
|
||||||
|
violations = append(violations, field+".methods must not be empty when remote access is declared")
|
||||||
|
}
|
||||||
|
for i, method := range remote.Methods {
|
||||||
|
if !validRemoteAccessMethod(method) {
|
||||||
|
violations = append(violations, fmt.Sprintf("%s.methods[%d] is not allowed", field, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
violations = append(violations, duplicateViolations(field+".methods", remote.Methods)...)
|
||||||
|
for i, capability := range remote.RunCapabilities {
|
||||||
|
if !validPluginRunCapability(capability) || !isRemoteRunCapability(capability) {
|
||||||
|
violations = append(violations, fmt.Sprintf("%s.runCapabilities[%d] is not allowed", field, i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !containsString(declaredCapabilities, capability) {
|
||||||
|
violations = append(violations, fmt.Sprintf("%s.runCapabilities[%d] must also be declared in capabilities", field, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
violations = append(violations, duplicateViolations(field+".runCapabilities", remote.RunCapabilities)...)
|
||||||
|
for i, engine := range remote.DatabaseEngines {
|
||||||
|
if !validRemoteDatabaseEngine(engine) {
|
||||||
|
violations = append(violations, fmt.Sprintf("%s.databaseEngines[%d] is not allowed", field, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
violations = append(violations, duplicateViolations(field+".databaseEngines", remote.DatabaseEngines)...)
|
||||||
|
if containsString(remote.Methods, "run") && len(remote.RunCapabilities) == 0 {
|
||||||
|
violations = append(violations, field+".runCapabilities must not be empty when run access is declared")
|
||||||
|
}
|
||||||
|
if containsString(remote.Methods, "ftp") && !containsAny(declaredCapabilities, []string{domain.JobCapabilityRemoteFTPRead, domain.JobCapabilityRemoteFTPWrite}) {
|
||||||
|
violations = append(violations, field+" requires remote.ftp.read or remote.ftp.write when ftp is declared")
|
||||||
|
}
|
||||||
|
if containsString(remote.Methods, "rsync") && !containsAny(declaredCapabilities, []string{domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite}) {
|
||||||
|
violations = append(violations, field+" requires remote.rsync.read or remote.rsync.write when rsync is declared")
|
||||||
|
}
|
||||||
|
if remote.RCON && !containsString(remote.RunCapabilities, domain.JobCapabilityRemoteRunRCONCommand) {
|
||||||
|
violations = append(violations, field+".rcon requires remote.run.rcon.command")
|
||||||
|
}
|
||||||
|
if remote.LogTransfer && !containsString(remote.RunCapabilities, domain.JobCapabilityRemoteRunLogsTransfer) {
|
||||||
|
violations = append(violations, field+".logTransfer requires remote.run.logs.transfer")
|
||||||
|
}
|
||||||
|
for _, engine := range remote.DatabaseEngines {
|
||||||
|
required := domain.JobCapabilityRemoteRunDBMySQLQuery
|
||||||
|
if engine == "sqlite" {
|
||||||
|
required = domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||||
|
}
|
||||||
|
if !containsString(remote.RunCapabilities, required) {
|
||||||
|
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
func validateSafePluginStrings(prefix string, values []fieldString) []string {
|
func validateSafePluginStrings(prefix string, values []fieldString) []string {
|
||||||
var violations []string
|
var violations []string
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
@@ -908,6 +1001,9 @@ func pluginSafeStrings(plugin domain.GamePlugin) []fieldString {
|
|||||||
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, "bridgeActions", plugin.BridgeActions)
|
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
||||||
|
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
||||||
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
||||||
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines)
|
||||||
for i, page := range plugin.Pages {
|
for i, page := range plugin.Pages {
|
||||||
prefix := fmt.Sprintf("pages[%d]", i)
|
prefix := fmt.Sprintf("pages[%d]", i)
|
||||||
values = append(values,
|
values = append(values,
|
||||||
@@ -944,6 +1040,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 = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
||||||
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
||||||
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines)
|
||||||
for i, page := range manifest.Pages {
|
for i, page := range manifest.Pages {
|
||||||
prefix := fmt.Sprintf("pages[%d]", i)
|
prefix := fmt.Sprintf("pages[%d]", i)
|
||||||
values = append(values,
|
values = append(values,
|
||||||
@@ -979,6 +1078,9 @@ func marketplacePluginSafeStrings(plugin domain.PluginMarketplacePlugin) []field
|
|||||||
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, "bridgeActions", plugin.BridgeActions)
|
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
||||||
|
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
||||||
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
||||||
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines)
|
||||||
for i, page := range plugin.Pages {
|
for i, page := range plugin.Pages {
|
||||||
prefix := fmt.Sprintf("pages[%d]", i)
|
prefix := fmt.Sprintf("pages[%d]", i)
|
||||||
values = append(values,
|
values = append(values,
|
||||||
@@ -1132,7 +1234,14 @@ func validPluginRunCapability(capability string) bool {
|
|||||||
"config.write",
|
"config.write",
|
||||||
"files.list", "files.read", "files.write", "files.patch",
|
"files.list", "files.read", "files.write", "files.patch",
|
||||||
"file.list", "file.read", "file.write", "file.patch",
|
"file.list", "file.read", "file.write", "file.patch",
|
||||||
"logs.read", "log.query",
|
"logs.read", "log.query", domain.JobCapabilityLogsBackfill,
|
||||||
|
domain.JobCapabilityRemoteFTPRead, domain.JobCapabilityRemoteFTPWrite,
|
||||||
|
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
|
||||||
|
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
|
||||||
|
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||||
|
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||||
|
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||||
|
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||||
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
|
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
|
||||||
"ai.invoke":
|
"ai.invoke":
|
||||||
return true
|
return true
|
||||||
@@ -1141,6 +1250,51 @@ func validPluginRunCapability(capability string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isRemoteRunCapability(capability string) bool {
|
||||||
|
return strings.HasPrefix(capability, "remote.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteCapabilityRequiresTargetKey(capability string) bool {
|
||||||
|
switch capability {
|
||||||
|
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return isRemoteRunCapability(capability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteCapabilityRequiresInputRef(capability string) bool {
|
||||||
|
switch capability {
|
||||||
|
case domain.JobCapabilityRemoteFTPWrite,
|
||||||
|
domain.JobCapabilityRemoteRsyncWrite,
|
||||||
|
domain.JobCapabilityRemoteRunFilesWrite,
|
||||||
|
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
||||||
|
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||||
|
domain.JobCapabilityRemoteRunRCONCommand:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRemoteAccessMethod(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case "ftp", "rsync", "run":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRemoteDatabaseEngine(engine string) bool {
|
||||||
|
switch engine {
|
||||||
|
case "mysql", "sqlite":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validFileOperationKind(operation domain.FileOperationKind) bool {
|
func validFileOperationKind(operation domain.FileOperationKind) bool {
|
||||||
switch operation {
|
switch operation {
|
||||||
case domain.FileOperationRead, domain.FileOperationWrite:
|
case domain.FileOperationRead, domain.FileOperationWrite:
|
||||||
@@ -1186,7 +1340,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", "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", "ai.invoke":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -1200,6 +1354,11 @@ func validPluginBridgeAction(action domain.PluginBridgeAction) bool {
|
|||||||
domain.PluginBridgeActionLogsQuery,
|
domain.PluginBridgeActionLogsQuery,
|
||||||
domain.PluginBridgeActionArtifactsOpen,
|
domain.PluginBridgeActionArtifactsOpen,
|
||||||
domain.PluginBridgeActionFilesRequest,
|
domain.PluginBridgeActionFilesRequest,
|
||||||
|
domain.PluginBridgeActionRemoteAccessRequest,
|
||||||
|
domain.PluginBridgeActionRunDistribution,
|
||||||
|
domain.PluginBridgeActionDependenciesRequest,
|
||||||
|
domain.PluginBridgeActionLogsBackfillRequest,
|
||||||
|
domain.PluginBridgeActionClientManager,
|
||||||
domain.PluginBridgeActionAIInvoke:
|
domain.PluginBridgeActionAIInvoke:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
@@ -1219,6 +1378,16 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
|||||||
return []string{"server.artifacts.read"}
|
return []string{"server.artifacts.read"}
|
||||||
case domain.PluginBridgeActionFilesRequest:
|
case domain.PluginBridgeActionFilesRequest:
|
||||||
return []string{"server.files.read"}
|
return []string{"server.files.read"}
|
||||||
|
case domain.PluginBridgeActionRemoteAccessRequest:
|
||||||
|
return []string{"server.remote.access"}
|
||||||
|
case domain.PluginBridgeActionRunDistribution:
|
||||||
|
return []string{"server.run.distribution"}
|
||||||
|
case domain.PluginBridgeActionDependenciesRequest:
|
||||||
|
return []string{"server.dependencies.manage"}
|
||||||
|
case domain.PluginBridgeActionLogsBackfillRequest:
|
||||||
|
return []string{"server.logs.read"}
|
||||||
|
case domain.PluginBridgeActionClientManager:
|
||||||
|
return []string{"server.client-manager.manage"}
|
||||||
case domain.PluginBridgeActionAIInvoke:
|
case domain.PluginBridgeActionAIInvoke:
|
||||||
return []string{"ai.invoke"}
|
return []string{"ai.invoke"}
|
||||||
default:
|
default:
|
||||||
@@ -1259,6 +1428,15 @@ func containsAll(values []string, required []string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsAny(values []string, candidates []string) bool {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if containsString(values, candidate) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func containsString(values []string, target string) bool {
|
func containsString(values []string, target string) bool {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if value == target {
|
if value == target {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Do not define API clients, shared DTOs, route definitions, schemas, or bridge co
|
|||||||
|
|
||||||
## Interaction Rules
|
## Interaction Rules
|
||||||
|
|
||||||
Do not use fixed left-list/right-detail master-detail layouts for server or plugin details. Use detail routes, modals, or drawers.
|
Management list pages must keep the primary list, grid, or table as the full-width working surface. Do not add permanent right-side create/edit/detail panes or fixed left-list/right-form master-detail layouts for users, plugins, AI providers, servers, or similar management resources. Use modals, drawers, or detail routes for create, edit, and detail workflows unless a future OpenSpec change explicitly requires an inline split layout.
|
||||||
|
|
||||||
## Visual Style Rules
|
## Visual Style Rules
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -74,13 +74,29 @@ npm run dev
|
|||||||
|
|
||||||
For Docker, the web console is built with `VITE_PLATFORM_API_BASE_URL=/api/v1` and served by Nginx. Nginx proxies `/api/v1` and `/healthz` to the `platform` compose service, so browser code never needs a direct backend container address.
|
For Docker, the web console is built with `VITE_PLATFORM_API_BASE_URL=/api/v1` and served by Nginx. Nginx proxies `/api/v1` and `/healthz` to the `platform` compose service, so browser code never needs a direct backend container address.
|
||||||
|
|
||||||
Current UI behavior is a browser-verifiable console shell with the required first-party page routes. Data-backed workflows, plugin page hosting, and API integration belong to later OpenSpec changes.
|
Current UI behavior is a browser-verifiable API-backed management console with the required first-party page routes. Server management now includes platform-mediated lifecycle, config, administrator, runtime distribution, dependency, and log workflows.
|
||||||
|
|
||||||
|
Server list and server detail surfaces expose runtime actions through platform APIs:
|
||||||
|
|
||||||
|
- generate run packages for selected OS/architecture targets.
|
||||||
|
- download the latest authorized run package.
|
||||||
|
- push a self-update job to an online run endpoint when the endpoint reports `run.self-update`.
|
||||||
|
- reset the current run key, which invalidates older run packages until regenerated.
|
||||||
|
- generate and download plugin-declared client-manager packages, including SCUM-style companion managers.
|
||||||
|
- reset the current client-manager key separately from the run key.
|
||||||
|
- request dependency checks and typed dependency install jobs.
|
||||||
|
- open live server log stream metadata and request historical log backfill jobs.
|
||||||
|
|
||||||
|
These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies.
|
||||||
|
|
||||||
Browser walkthrough baseline:
|
Browser walkthrough baseline:
|
||||||
|
|
||||||
1. Start `npm run dev`.
|
1. Start `npm run dev`.
|
||||||
2. Open the local Vite URL.
|
2. Open the local Vite URL.
|
||||||
3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths.
|
3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths.
|
||||||
|
4. In 服务器管理, verify the server card action menu contains runtime actions without turning the whole card into an accidental click target.
|
||||||
|
5. In a server detail route, verify the overview renders the 运行分发 section, action availability reasons, dependency/log controls, and safe redacted refs only.
|
||||||
|
6. Switch black mecha and magical-girl themes when UI styling changed; runtime controls must keep the shared translucent console surfaces and avoid nested double frames.
|
||||||
|
|
||||||
Automated browser acceptance uses the repository local debug stack:
|
Automated browser acceptance uses the repository local debug stack:
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
name: "服务器管理",
|
name: "服务器管理",
|
||||||
hash: "#/servers",
|
hash: "#/servers",
|
||||||
markers: ["服务器管理", server.name, server.id, "创建服务器", "全部", "离线"]
|
markers: ["服务器管理", server.name, server.id, "创建服务器", "全部", "离线", "运行操作"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "插件市场",
|
name: "插件市场",
|
||||||
@@ -126,7 +126,8 @@ async function main() {
|
|||||||
"插件市场",
|
"插件市场",
|
||||||
"平台 API",
|
"平台 API",
|
||||||
marketplacePlugin.id,
|
marketplacePlugin.id,
|
||||||
marketplacePlugin.manifestRef,
|
marketplacePlugin.name,
|
||||||
|
"Development plugin",
|
||||||
"process.install",
|
"process.install",
|
||||||
"process.start",
|
"process.start",
|
||||||
"process.stop",
|
"process.stop",
|
||||||
@@ -144,7 +145,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
name: "AI 提供商管理",
|
name: "AI 提供商管理",
|
||||||
hash: "#/aiProviders",
|
hash: "#/aiProviders",
|
||||||
markers: ["AI 提供商管理", "已连接", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
|
markers: ["AI 提供商管理", "平台 API", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "服务器详情",
|
name: "服务器详情",
|
||||||
@@ -152,6 +153,10 @@ async function main() {
|
|||||||
markers: [
|
markers: [
|
||||||
server.name,
|
server.name,
|
||||||
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
|
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
|
||||||
|
"运行分发",
|
||||||
|
"run 包",
|
||||||
|
"客户端管理器",
|
||||||
|
"依赖",
|
||||||
"启动",
|
"启动",
|
||||||
"停止",
|
"停止",
|
||||||
"日志",
|
"日志",
|
||||||
@@ -166,6 +171,14 @@ async function main() {
|
|||||||
for (const route of routeChecks) {
|
for (const route of routeChecks) {
|
||||||
const state = await verifyBrowserRoute(chrome, route.hash, route.markers, route.name);
|
const state = await verifyBrowserRoute(chrome, route.hash, route.markers, route.name);
|
||||||
evidence.routes.push(state);
|
evidence.routes.push(state);
|
||||||
|
if (route.name === "服务器管理") {
|
||||||
|
const runtimeMenu = await verifyServerQuickRuntimeMenu(chrome, route.name);
|
||||||
|
evidence.routes.push({
|
||||||
|
name: "服务器管理 / 运行操作菜单",
|
||||||
|
url: await chrome.url(),
|
||||||
|
...runtimeMenu
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
||||||
@@ -295,6 +308,20 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
|
|||||||
layout,
|
layout,
|
||||||
textSample: state.textSample
|
textSample: state.textSample
|
||||||
});
|
});
|
||||||
|
if (route.name === "服务器管理") {
|
||||||
|
const runtimeMenu = await verifyServerQuickRuntimeMenu(chrome, `${scenario.name} / 服务器管理`);
|
||||||
|
const runtimeMenuLayout = await chrome.layoutSnapshot();
|
||||||
|
assertNoVisibleLayoutIssues(runtimeMenuLayout, `${scenario.name} / 服务器管理 / 运行操作菜单`);
|
||||||
|
routeEvidence.push({
|
||||||
|
name: "服务器管理 / 运行操作菜单",
|
||||||
|
url: await chrome.url(),
|
||||||
|
requiredMarkers: runtimeMenu.requiredMarkers,
|
||||||
|
fallbackScan: runtimeMenu.fallbackScan,
|
||||||
|
forbiddenFragmentScan: runtimeMenu.forbiddenFragmentScan,
|
||||||
|
layout: runtimeMenuLayout,
|
||||||
|
textSample: runtimeMenu.textSample
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await chrome.evaluate(() => {
|
await chrome.evaluate(() => {
|
||||||
@@ -372,6 +399,31 @@ async function clickAndVerify(chrome, buttonText, markers) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyServerQuickRuntimeMenu(chrome, label) {
|
||||||
|
const markers = ["生成 run", "下载 run", "推送更新", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const summary = Array.from(document.querySelectorAll("summary")).find((item) => item.textContent?.includes("运行操作"));
|
||||||
|
if (!(summary instanceof HTMLElement)) {
|
||||||
|
throw new Error("runtime action menu summary not found");
|
||||||
|
}
|
||||||
|
const details = summary.closest("details");
|
||||||
|
if (!(details instanceof HTMLDetailsElement)) {
|
||||||
|
throw new Error("runtime action menu container not found");
|
||||||
|
}
|
||||||
|
details.open = true;
|
||||||
|
});
|
||||||
|
await chrome.waitForText(markers, `${label} runtime action menu`);
|
||||||
|
const visibleText = await chrome.visibleText();
|
||||||
|
assertMarkers(visibleText, markers, `${label} runtime action menu`);
|
||||||
|
scanText(visibleText, `${label} runtime action menu`);
|
||||||
|
return {
|
||||||
|
requiredMarkers: markers,
|
||||||
|
fallbackScan: "passed",
|
||||||
|
forbiddenFragmentScan: "passed",
|
||||||
|
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")) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { PlatformApiClient, setPlatformApiSessionToken } from "./client";
|
import { PlatformApiClient, setPlatformApiSessionToken } from "./client";
|
||||||
import type { AiProviderResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types";
|
import type { AiProviderResponse, ArtifactDownloadReferenceResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types";
|
||||||
|
|
||||||
const provider: AiProviderResponse = {
|
const provider: AiProviderResponse = {
|
||||||
id: "ai.openai",
|
id: "ai.openai",
|
||||||
@@ -105,6 +105,37 @@ const artifact = {
|
|||||||
updatedAt: "2026-07-03T00:00:00Z"
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runtimeActions = {
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
runEndpointId: endpoint.id,
|
||||||
|
runStatus: "online",
|
||||||
|
actions: [
|
||||||
|
{ key: "generate-run", label: "Generate run", available: true },
|
||||||
|
{ key: "download-run", label: "Download run", available: true },
|
||||||
|
{ key: "push-run-update", label: "Push run update", available: true },
|
||||||
|
{ key: "generate-client-manager", label: "Generate client manager", available: true },
|
||||||
|
{ key: "dependencies-check", label: "Check dependencies", available: true },
|
||||||
|
{ key: "historical-logs", label: "Historical logs", available: true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeDownload: ArtifactDownloadReferenceResponse = {
|
||||||
|
artifactId: "artifact-run-1",
|
||||||
|
ownerKind: "server-instance",
|
||||||
|
ownerId: server.id,
|
||||||
|
filename: "run-linux-amd64.zip",
|
||||||
|
contentType: "application/zip",
|
||||||
|
sizeBytes: 128,
|
||||||
|
checksum: "sha256:runchecksum",
|
||||||
|
state: "available",
|
||||||
|
downloadUrl: "/api/v1/artifacts/artifact-run-1/content",
|
||||||
|
expiresAt: "2026-07-03T00:15:00Z",
|
||||||
|
rangeSupported: true,
|
||||||
|
chunkSizeBytes: 1048576,
|
||||||
|
storageBehavior: "platform-memory-transfer-session"
|
||||||
|
};
|
||||||
|
|
||||||
describe("PlatformApiClient AI providers", () => {
|
describe("PlatformApiClient AI providers", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setPlatformApiSessionToken(null);
|
setPlatformApiSessionToken(null);
|
||||||
@@ -160,6 +191,13 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
if (url.endsWith("/api/v1/server-instances") && (!init?.method || init.method === "GET")) {
|
if (url.endsWith("/api/v1/server-instances") && (!init?.method || init.method === "GET")) {
|
||||||
return jsonResponse({ items: [server], count: 1 });
|
return jsonResponse({ items: [server], count: 1 });
|
||||||
}
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "PUT") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ name: "Example Survival Renamed" });
|
||||||
|
return jsonResponse({ ...server, name: "Example Survival Renamed" });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "DELETE") {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
if (url.endsWith("/api/v1/metrics/platform")) {
|
if (url.endsWith("/api/v1/metrics/platform")) {
|
||||||
return jsonResponse({ cpuPercent: 28, memoryPercent: 42, diskPercent: 19, source: "platform-derived", collectedAt: "2026-07-03T00:00:00Z" });
|
return jsonResponse({ cpuPercent: 28, memoryPercent: 42, diskPercent: 19, source: "platform-derived", collectedAt: "2026-07-03T00:00:00Z" });
|
||||||
}
|
}
|
||||||
@@ -314,6 +352,122 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
if (url.endsWith("/api/v1/server-instances/server-1/administrators/user-2") && init?.method === "DELETE") {
|
if (url.endsWith("/api/v1/server-instances/server-1/administrators/user-2") && init?.method === "DELETE") {
|
||||||
return jsonResponse({ ...server, adminUserIds: [] });
|
return jsonResponse({ ...server, adminUserIds: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/runtime/actions")) {
|
||||||
|
return jsonResponse(runtimeActions);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/run/generate") && init?.method === "POST") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" });
|
||||||
|
return jsonResponse({
|
||||||
|
id: "run-dist-1",
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
runEndpointId: endpoint.id,
|
||||||
|
targetOs: "linux",
|
||||||
|
targetArch: "amd64",
|
||||||
|
packageFormat: "zip",
|
||||||
|
artifactId: "artifact-run-1",
|
||||||
|
checksum: "sha256:runchecksum",
|
||||||
|
keyGeneration: 1,
|
||||||
|
secretRef: "secret://runtime-keys/server-1/run/current",
|
||||||
|
status: "available",
|
||||||
|
createdAt: "2026-07-03T00:00:00Z",
|
||||||
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/run/download") && init?.method === "POST") {
|
||||||
|
return jsonResponse(runtimeDownload);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/run/key/reset") && init?.method === "POST") {
|
||||||
|
return jsonResponse({
|
||||||
|
id: "runtime-key-server-1-run-2",
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
componentKind: "run",
|
||||||
|
secretRef: "secret://runtime-keys/server-1/run/current",
|
||||||
|
fingerprint: "abc123def456",
|
||||||
|
generation: 2,
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-07-03T00:00:00Z",
|
||||||
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/run/update") && init?.method === "POST") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" });
|
||||||
|
return jsonResponse({
|
||||||
|
id: "run-update-1",
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
runEndpointId: endpoint.id,
|
||||||
|
artifactId: "artifact-run-1",
|
||||||
|
checksum: "sha256:runchecksum",
|
||||||
|
jobId: "job-run-update",
|
||||||
|
idempotencyKey: "idem-run-update",
|
||||||
|
status: "queued",
|
||||||
|
createdAt: "2026-07-03T00:00:00Z",
|
||||||
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/generate") && init?.method === "POST") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
|
profileKey: "scum-client-manager",
|
||||||
|
targetOs: "windows",
|
||||||
|
targetArch: "amd64",
|
||||||
|
repositoryUrl: "https://github.com/F88888/scum_client.git",
|
||||||
|
sourceRevision: "main",
|
||||||
|
idempotencyKey: "idem-client-generate"
|
||||||
|
});
|
||||||
|
return jsonResponse({
|
||||||
|
id: "client-dist-1",
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
profileKey: "scum-client-manager",
|
||||||
|
targetOs: "windows",
|
||||||
|
targetArch: "amd64",
|
||||||
|
repositoryUrl: "https://github.com/F88888/scum_client.git",
|
||||||
|
sourceRevision: "main",
|
||||||
|
buildJobId: "client-build-1",
|
||||||
|
artifactId: "artifact-client-1",
|
||||||
|
checksum: "sha256:clientchecksum",
|
||||||
|
keyGeneration: 1,
|
||||||
|
secretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current",
|
||||||
|
status: "available",
|
||||||
|
createdAt: "2026-07-03T00:00:00Z",
|
||||||
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/download") && init?.method === "POST") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ profileKey: "scum-client-manager" });
|
||||||
|
return jsonResponse({ ...runtimeDownload, artifactId: "artifact-client-1", filename: "scum-client-manager.exe" });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/key/reset") && init?.method === "POST") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ componentKind: "client-manager", componentKey: "scum-client-manager" });
|
||||||
|
return jsonResponse({
|
||||||
|
id: "runtime-key-server-1-client-2",
|
||||||
|
serverInstanceId: server.id,
|
||||||
|
componentKind: "client-manager",
|
||||||
|
componentKey: "scum-client-manager",
|
||||||
|
secretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current",
|
||||||
|
fingerprint: "def456abc123",
|
||||||
|
generation: 2,
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-07-03T00:00:00Z",
|
||||||
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/dependencies/check") && init?.method === "POST") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", idempotencyKey: "idem-dep-check" });
|
||||||
|
return jsonResponse({ ...job, id: "job-dep-check", capability: "dependencies.check", targetKey: "dependencies/java-21" });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/dependencies/install") && init?.method === "POST") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" });
|
||||||
|
return jsonResponse({ ...job, id: "job-dep-install", capability: "dependencies.install", targetKey: "dependencies/install/install-java-linux" });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/logs/live")) {
|
||||||
|
return jsonResponse({ items: [], count: 0 });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/logs/backfill") && init?.method === "POST") {
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" });
|
||||||
|
return jsonResponse({ ...job, id: "job-log-backfill", capability: "logs.backfill", targetKey: "logs/latest-log", inputRef: "artifact://logs/checkpoint/1" });
|
||||||
|
}
|
||||||
if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") {
|
if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
pluginId: plugin.id,
|
pluginId: plugin.id,
|
||||||
@@ -371,6 +525,8 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
await expect(client.health()).resolves.toMatchObject({ status: "ok" });
|
await expect(client.health()).resolves.toMatchObject({ status: "ok" });
|
||||||
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
|
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
|
||||||
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
|
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
|
||||||
|
await expect(client.updateServerInstance(server.id, { name: "Example Survival Renamed" })).resolves.toMatchObject({ name: "Example Survival Renamed" });
|
||||||
|
await expect(client.archiveServerInstance(server.id)).resolves.toBeUndefined();
|
||||||
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
|
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
|
||||||
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
|
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
|
||||||
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
|
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
|
||||||
@@ -398,6 +554,41 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
|
await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
|
||||||
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
|
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
|
||||||
await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] });
|
await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] });
|
||||||
|
|
||||||
|
const runtime = await client.getServerRuntimeActions(server.id);
|
||||||
|
expect(runtime.runStatus).toBe("online");
|
||||||
|
expect(runtime.actions.some((action) => action.key === "generate-run" && action.available)).toBe(true);
|
||||||
|
await expect(client.generateRunDistribution(server.id, { targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" })).resolves.toMatchObject({
|
||||||
|
artifactId: "artifact-run-1",
|
||||||
|
keyGeneration: 1,
|
||||||
|
secretRef: "secret://runtime-keys/server-1/run/current"
|
||||||
|
});
|
||||||
|
await expect(client.downloadLatestRunDistribution(server.id)).resolves.toMatchObject({ artifactId: "artifact-run-1", rangeSupported: true });
|
||||||
|
await expect(client.resetRunKey(server.id)).resolves.toMatchObject({ componentKind: "run", generation: 2 });
|
||||||
|
await expect(client.pushRunUpdate(server.id, { artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" })).resolves.toMatchObject({
|
||||||
|
jobId: "job-run-update",
|
||||||
|
status: "queued"
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
client.generateClientManager(server.id, {
|
||||||
|
profileKey: "scum-client-manager",
|
||||||
|
targetOs: "windows",
|
||||||
|
targetArch: "amd64",
|
||||||
|
repositoryUrl: "https://github.com/F88888/scum_client.git",
|
||||||
|
sourceRevision: "main",
|
||||||
|
idempotencyKey: "idem-client-generate"
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ artifactId: "artifact-client-1", profileKey: "scum-client-manager" });
|
||||||
|
await expect(client.downloadLatestClientManager(server.id, { profileKey: "scum-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" });
|
||||||
|
await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "scum-client-manager" })).resolves.toMatchObject({ generation: 2 });
|
||||||
|
await expect(client.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" });
|
||||||
|
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
|
||||||
|
capability: "dependencies.install"
|
||||||
|
});
|
||||||
|
await expect(client.listServerLiveLogs(server.id)).resolves.toMatchObject({ count: 0 });
|
||||||
|
await expect(client.requestLogBackfill(server.id, { sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" })).resolves.toMatchObject({
|
||||||
|
capability: "logs.backfill"
|
||||||
|
});
|
||||||
await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({
|
await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({
|
||||||
allowed: true
|
allowed: true
|
||||||
});
|
});
|
||||||
@@ -408,7 +599,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(24);
|
expect(fetchMock).toHaveBeenCalledTimes(38);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ import type {
|
|||||||
ArtifactListResponse,
|
ArtifactListResponse,
|
||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuditEventListResponse,
|
AuditEventListResponse,
|
||||||
|
ClientManagerBuildRequest,
|
||||||
|
ClientManagerDistributionResponse,
|
||||||
|
ClientManagerDownloadRequest,
|
||||||
|
ComponentKeyResponse,
|
||||||
|
ComponentKeyResetRequest,
|
||||||
CurrentUserResponse,
|
CurrentUserResponse,
|
||||||
|
DependencyJobRequest,
|
||||||
FileOperationDispatchRequest,
|
FileOperationDispatchRequest,
|
||||||
FileOperationDispatchResponse,
|
FileOperationDispatchResponse,
|
||||||
GamePluginListResponse,
|
GamePluginListResponse,
|
||||||
@@ -25,6 +31,7 @@ import type {
|
|||||||
JobResponse,
|
JobResponse,
|
||||||
LlmConfigSuggestionRequest,
|
LlmConfigSuggestionRequest,
|
||||||
LlmConfigSuggestionResponse,
|
LlmConfigSuggestionResponse,
|
||||||
|
LogBackfillRequest,
|
||||||
LogStreamCursorRequest,
|
LogStreamCursorRequest,
|
||||||
LogStreamCursorResponse,
|
LogStreamCursorResponse,
|
||||||
LogStreamListResponse,
|
LogStreamListResponse,
|
||||||
@@ -39,7 +46,11 @@ import type {
|
|||||||
PluginBridgeExecuteRequest,
|
PluginBridgeExecuteRequest,
|
||||||
PluginBridgeExecuteResponse,
|
PluginBridgeExecuteResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
RunDistributionGenerateRequest,
|
||||||
|
RunDistributionResponse,
|
||||||
RunEndpointListResponse,
|
RunEndpointListResponse,
|
||||||
|
RunUpdateJobResponse,
|
||||||
|
RunUpdateRequest,
|
||||||
ServerConfigResponse,
|
ServerConfigResponse,
|
||||||
ServerConfigDiffPreviewRequest,
|
ServerConfigDiffPreviewRequest,
|
||||||
ServerConfigDiffPreviewResponse,
|
ServerConfigDiffPreviewResponse,
|
||||||
@@ -49,10 +60,12 @@ import type {
|
|||||||
ServerConfigWriteApprovalRequest,
|
ServerConfigWriteApprovalRequest,
|
||||||
ServerConfigWriteDispatchResponse,
|
ServerConfigWriteDispatchResponse,
|
||||||
ServerInstanceListResponse,
|
ServerInstanceListResponse,
|
||||||
|
ServerInstanceUpdateRequest,
|
||||||
ServerInstanceResponse,
|
ServerInstanceResponse,
|
||||||
ServerMemberListResponse,
|
ServerMemberListResponse,
|
||||||
ServerMemberRequest,
|
ServerMemberRequest,
|
||||||
ServerMetricsListResponse,
|
ServerMetricsListResponse,
|
||||||
|
ServerRuntimeActionsResponse,
|
||||||
UserCreateRequest,
|
UserCreateRequest,
|
||||||
UserListResponse,
|
UserListResponse,
|
||||||
UserProfileUpdateRequest,
|
UserProfileUpdateRequest,
|
||||||
@@ -186,6 +199,84 @@ export class PlatformApiClient {
|
|||||||
return this.request<JobResponse>(`/jobs/${encodeURIComponent(id)}`);
|
return this.request<JobResponse>(`/jobs/${encodeURIComponent(id)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getServerRuntimeActions(id: string): Promise<ServerRuntimeActionsResponse> {
|
||||||
|
return this.request<ServerRuntimeActionsResponse>(`/server-instances/${encodeURIComponent(id)}/runtime/actions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateRunDistribution(id: string, request: RunDistributionGenerateRequest): Promise<RunDistributionResponse> {
|
||||||
|
return this.request<RunDistributionResponse>(`/server-instances/${encodeURIComponent(id)}/run/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadLatestRunDistribution(id: string): Promise<ArtifactDownloadReferenceResponse> {
|
||||||
|
return this.request<ArtifactDownloadReferenceResponse>(`/server-instances/${encodeURIComponent(id)}/run/download`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetRunKey(id: string): Promise<ComponentKeyResponse> {
|
||||||
|
return this.request<ComponentKeyResponse>(`/server-instances/${encodeURIComponent(id)}/run/key/reset`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushRunUpdate(id: string, request: RunUpdateRequest): Promise<RunUpdateJobResponse> {
|
||||||
|
return this.request<RunUpdateJobResponse>(`/server-instances/${encodeURIComponent(id)}/run/update`, {
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateClientManager(id: string, request: ClientManagerBuildRequest): Promise<ClientManagerDistributionResponse> {
|
||||||
|
return this.request<ClientManagerDistributionResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadLatestClientManager(id: string, request: ClientManagerDownloadRequest = {}): Promise<ArtifactDownloadReferenceResponse> {
|
||||||
|
return this.request<ArtifactDownloadReferenceResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/download`, {
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetClientManagerKey(id: string, request: ComponentKeyResetRequest): Promise<ComponentKeyResponse> {
|
||||||
|
return this.request<ComponentKeyResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/key/reset`, {
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
|
||||||
|
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/check`, {
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async installDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
|
||||||
|
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/install`, {
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listServerLiveLogs(id: string): Promise<LogStreamListResponse> {
|
||||||
|
return this.request<LogStreamListResponse>(`/server-instances/${encodeURIComponent(id)}/logs/live`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestLogBackfill(id: string, request: LogBackfillRequest): Promise<JobResponse> {
|
||||||
|
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/logs/backfill`, {
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createJob(request: JobCreateRequest): Promise<JobResponse> {
|
async createJob(request: JobCreateRequest): Promise<JobResponse> {
|
||||||
return this.request<JobResponse>("/jobs", { method: "POST", body: request });
|
return this.request<JobResponse>("/jobs", { method: "POST", body: request });
|
||||||
}
|
}
|
||||||
@@ -230,6 +321,17 @@ export class PlatformApiClient {
|
|||||||
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}`);
|
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateServerInstance(id: string, request: ServerInstanceUpdateRequest): Promise<ServerInstanceResponse> {
|
||||||
|
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async archiveServerInstance(id: string): Promise<void> {
|
||||||
|
return this.request<void>(`/server-instances/${encodeURIComponent(id)}`, { method: "DELETE", parseJson: false });
|
||||||
|
}
|
||||||
|
|
||||||
async getPlatformResourceUsage(): Promise<PlatformResourceUsageResponse> {
|
async getPlatformResourceUsage(): Promise<PlatformResourceUsageResponse> {
|
||||||
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
|
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ export interface ServerInstanceResponse {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerInstanceUpdateRequest {
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerInstanceListResponse {
|
export interface ServerInstanceListResponse {
|
||||||
items: ServerInstanceResponse[];
|
items: ServerInstanceResponse[];
|
||||||
count: number;
|
count: number;
|
||||||
@@ -233,6 +237,129 @@ export interface ArtifactContentChunk {
|
|||||||
storageBehavior?: string;
|
storageBehavior?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerRuntimeActionResponse {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
available: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRuntimeActionsResponse {
|
||||||
|
serverInstanceId: string;
|
||||||
|
pluginId: string;
|
||||||
|
runEndpointId: string;
|
||||||
|
runStatus: string;
|
||||||
|
actions: ServerRuntimeActionResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunDistributionGenerateRequest {
|
||||||
|
targetOs: string;
|
||||||
|
targetArch: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunDistributionResponse {
|
||||||
|
id: string;
|
||||||
|
serverInstanceId: string;
|
||||||
|
pluginId: string;
|
||||||
|
runEndpointId: string;
|
||||||
|
targetOs: string;
|
||||||
|
targetArch: string;
|
||||||
|
packageFormat: string;
|
||||||
|
artifactId: string;
|
||||||
|
checksum: string;
|
||||||
|
keyGeneration: number;
|
||||||
|
secretRef: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunUpdateRequest {
|
||||||
|
artifactId: string;
|
||||||
|
checksum?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunUpdateJobResponse {
|
||||||
|
id: string;
|
||||||
|
serverInstanceId: string;
|
||||||
|
runEndpointId: string;
|
||||||
|
artifactId: string;
|
||||||
|
checksum: string;
|
||||||
|
jobId?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientManagerBuildRequest {
|
||||||
|
profileKey: string;
|
||||||
|
targetOs: string;
|
||||||
|
targetArch: string;
|
||||||
|
repositoryUrl: string;
|
||||||
|
sourceRevision?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientManagerDistributionResponse {
|
||||||
|
id: string;
|
||||||
|
serverInstanceId: string;
|
||||||
|
pluginId: string;
|
||||||
|
profileKey: string;
|
||||||
|
targetOs: string;
|
||||||
|
targetArch: string;
|
||||||
|
repositoryUrl: string;
|
||||||
|
sourceRevision: string;
|
||||||
|
buildJobId: string;
|
||||||
|
artifactId: string;
|
||||||
|
checksum: string;
|
||||||
|
keyGeneration: number;
|
||||||
|
secretRef: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientManagerDownloadRequest {
|
||||||
|
profileKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentKeyResetRequest {
|
||||||
|
componentKind: "run" | "client-manager" | string;
|
||||||
|
componentKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentKeyResponse {
|
||||||
|
id: string;
|
||||||
|
serverInstanceId: string;
|
||||||
|
componentKind: string;
|
||||||
|
componentKey?: string;
|
||||||
|
secretRef: string;
|
||||||
|
fingerprint: string;
|
||||||
|
generation: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
resetAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DependencyJobRequest {
|
||||||
|
probeKey: string;
|
||||||
|
installPlanKey?: string;
|
||||||
|
targetOs?: string;
|
||||||
|
targetArch?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogBackfillRequest {
|
||||||
|
sourceKey: string;
|
||||||
|
checkpointRef?: string;
|
||||||
|
limit?: number;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginBridgeAuthorizeRequest {
|
export interface PluginBridgeAuthorizeRequest {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
routeKey: string;
|
routeKey: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
@@ -35,6 +36,36 @@ export function ConfirmDialog({ open, title, description, confirmLabel, danger,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ManagementDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManagementDialog({ open, title, description, wide, onClose, children }: ManagementDialogProps) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="confirm-backdrop management-dialog-backdrop" role="presentation" onClick={onClose}>
|
||||||
|
<div className={`drawer-panel management-dialog-panel${wide ? " management-dialog-wide" : ""}`} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button type="button" className="theme-upload drawer-close" onClick={onClose}>
|
||||||
|
<X size={14} />
|
||||||
|
<span>关闭</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{description && <p className="dialog-description">{description}</p>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface UsageMeterProps {
|
interface UsageMeterProps {
|
||||||
label: string;
|
label: string;
|
||||||
percent?: number;
|
percent?: number;
|
||||||
|
|||||||
@@ -23,14 +23,16 @@ export function PageFrame({ kicker, title, status, metrics }: PageFrameProps) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="page-status">{status}</span>
|
<span className="page-status">{status}</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="metric-grid">
|
{metrics.length > 0 && (
|
||||||
|
<dl className="page-summary-strip" aria-label={`${title} 快速状态`}>
|
||||||
{metrics.map((metric) => (
|
{metrics.map((metric) => (
|
||||||
<article key={metric.label} className={cx("metric-card", `metric-tone-${metric.tone}`)}>
|
<div key={metric.label} className={cx("page-summary-chip", `summary-tone-${metric.tone}`)}>
|
||||||
<span className="metric-label">{metric.label}</span>
|
<dt>{metric.label}</dt>
|
||||||
<strong className="metric-value">{metric.value}</strong>
|
<dd>{metric.value}</dd>
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { AiProviderKind, AiProviderResponse, AiProviderStatus, AiRelayMode } from "../api/types";
|
import type { AiProviderKind, AiProviderResponse, AiProviderStatus, AiRelayMode } from "../api/types";
|
||||||
|
|
||||||
export type AiProviderFilter = AiProviderStatus | "all";
|
export type AiProviderFilter = AiProviderStatus | "all";
|
||||||
export type AiProviderViewState = "api" | "local" | "saving" | "error";
|
export type AiProviderListState = "loading" | "ready" | "error";
|
||||||
|
export type AiProviderViewState = "api" | "local-development" | "saving" | "error";
|
||||||
|
|
||||||
export interface AiProviderFormState {
|
export interface AiProviderFormState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,22 +31,34 @@ export interface AiProviderActionState {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiProviderPageInitialState {
|
||||||
|
providers?: AiProviderResponse[];
|
||||||
|
selectedId?: string;
|
||||||
|
listState?: AiProviderListState;
|
||||||
|
listError?: string;
|
||||||
|
source?: AiProviderViewState;
|
||||||
|
action?: AiProviderActionState | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyAiProviderForm(): AiProviderFormState {
|
export function emptyAiProviderForm(): AiProviderFormState {
|
||||||
return {
|
return {
|
||||||
id: "",
|
id: "",
|
||||||
name: "",
|
name: "",
|
||||||
kind: "openai-compatible",
|
kind: "openai-compatible",
|
||||||
baseUrl: "https://api.example.test/v1",
|
baseUrl: "",
|
||||||
apiKeyRef: "secret://providers/",
|
apiKeyRef: "secret://providers/",
|
||||||
modelsText: "gpt-4.1-mini",
|
modelsText: "",
|
||||||
defaultModel: "gpt-4.1-mini",
|
defaultModel: "",
|
||||||
relayMode: "direct",
|
relayMode: "relay",
|
||||||
timeoutMs: "30000",
|
timeoutMs: "30000",
|
||||||
redactionPolicy: "default"
|
redactionPolicy: "default"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aiProviderToForm(provider: AiProviderResponse): AiProviderFormState {
|
export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormState {
|
||||||
|
if (!provider) {
|
||||||
|
return emptyAiProviderForm();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: provider.id,
|
id: provider.id,
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
|
|
||||||
export type ServerWorkflowViewState = "api" | "local" | "saving";
|
export type ServerWorkflowViewState = "api" | "local" | "saving";
|
||||||
export type ServerLifecycleActionLabel = "create" | "start" | "stop" | "refresh";
|
export type ServerLifecycleActionLabel = "create" | "start" | "stop" | "refresh";
|
||||||
|
export type ServerRemovalAction = "archive";
|
||||||
|
|
||||||
export interface ServerCreateFormState {
|
export interface ServerCreateFormState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,17 @@ export interface ServerWorkflowActionState {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerMetadataFormState {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRemovalConfirmationState {
|
||||||
|
action: ServerRemovalAction;
|
||||||
|
serverInstanceId: string;
|
||||||
|
name: string;
|
||||||
|
state: ServerInstanceState;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerManagementSummary {
|
export interface ServerManagementSummary {
|
||||||
total: number;
|
total: number;
|
||||||
running: number;
|
running: number;
|
||||||
@@ -83,3 +95,11 @@ export function defaultServerCreateForm(plugins: GamePluginResponse[], endpoints
|
|||||||
runEndpointId: endpoints[0]?.id ?? ""
|
runEndpointId: endpoints[0]?.id ?? ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serverMetadataFormFromInstance(instance: ServerInstanceResponse): ServerMetadataFormState {
|
||||||
|
return { name: instance.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canArchiveServer(state: ServerInstanceState): boolean {
|
||||||
|
return state !== "running" && state !== "installing" && state !== "deleted";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { UserResponse, UserStatus } from "../api/types";
|
||||||
|
|
||||||
|
export type UserListSource = "api" | "local-development";
|
||||||
|
export type UserRemovalAction = "deactivate";
|
||||||
|
|
||||||
|
export interface UserEditFormState {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
qq: string;
|
||||||
|
contactNote: string;
|
||||||
|
roles: string[];
|
||||||
|
status: UserStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRemovalConfirmationState {
|
||||||
|
action: UserRemovalAction;
|
||||||
|
user: UserResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userEditFormFromResponse(user: UserResponse): UserEditFormState {
|
||||||
|
return {
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email ?? "",
|
||||||
|
phone: user.profile?.phone ?? "",
|
||||||
|
qq: user.profile?.qq ?? "",
|
||||||
|
contactNote: user.profile?.contactNote ?? "",
|
||||||
|
roles: [...user.roles],
|
||||||
|
status: user.status
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,21 +2,68 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { AiProvidersPage } from "./AiProvidersPage";
|
import { AiProvidersPage } from "./AiProvidersPage";
|
||||||
|
import type { AiProviderResponse } from "../api/types";
|
||||||
|
|
||||||
|
const provider: AiProviderResponse = {
|
||||||
|
id: "ai.openai",
|
||||||
|
name: "OpenAI Relay",
|
||||||
|
kind: "openai-compatible",
|
||||||
|
baseUrl: "https://relay.example.test/v1",
|
||||||
|
apiKeyRef: "secret://providers/openai",
|
||||||
|
models: ["gpt-4.1", "gpt-4.1-mini"],
|
||||||
|
defaultModel: "gpt-4.1-mini",
|
||||||
|
relayMode: "relay",
|
||||||
|
timeoutMs: 30000,
|
||||||
|
status: "active",
|
||||||
|
redactionPolicy: "default"
|
||||||
|
};
|
||||||
|
|
||||||
describe("AiProvidersPage", () => {
|
describe("AiProvidersPage", () => {
|
||||||
it("renders the AI provider management workflow", () => {
|
it("renders the loading state before API data arrives", () => {
|
||||||
const html = renderToStaticMarkup(<AiProvidersPage />);
|
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ listState: "loading" }} />);
|
||||||
|
|
||||||
expect(html).toContain("AI 提供商管理");
|
expect(html).toContain("AI 提供商管理");
|
||||||
|
expect(html).toContain("正在加载 AI 提供商");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty API state when the platform returns no providers", () => {
|
||||||
|
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [], listState: "ready", source: "api" }} />);
|
||||||
|
|
||||||
|
expect(html).toContain("暂无 AI 提供商");
|
||||||
|
expect(html).toContain("page-summary-chip");
|
||||||
|
expect(html).not.toContain("metric-card");
|
||||||
|
expect(html).toContain("平台还没有返回任何提供商");
|
||||||
|
expect(html).toContain("新增提供商");
|
||||||
|
expect(html).not.toContain('value="secret://providers/"');
|
||||||
|
expect(html).not.toContain("OpenAI Relay");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders local-development fixtures with an explicit status label", () => {
|
||||||
|
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], listState: "ready", source: "local-development" }} />);
|
||||||
|
|
||||||
expect(html).toContain("OpenAI Relay");
|
expect(html).toContain("OpenAI Relay");
|
||||||
expect(html).toContain("Local Ollama");
|
expect(html).toContain("本地开发");
|
||||||
expect(html).toContain("新增");
|
|
||||||
expect(html).toContain("保存");
|
|
||||||
expect(html).toContain("secret://providers/openai");
|
expect(html).toContain("secret://providers/openai");
|
||||||
|
expect(html).toContain("测试");
|
||||||
|
expect(html).toContain("模型");
|
||||||
|
expect(html).toContain("编辑");
|
||||||
|
expect(html).toContain("更多");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders guided provider setup for an existing provider", () => {
|
||||||
|
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], selectedId: provider.id, listState: "ready", source: "api" }} />);
|
||||||
|
|
||||||
|
expect(html).toContain("配置流程");
|
||||||
|
expect(html).toContain("提供商预设");
|
||||||
|
expect(html).toContain("保存前检查");
|
||||||
|
expect(html).toContain("测试已保存配置");
|
||||||
|
expect(html).toContain("发现模型并填入");
|
||||||
|
expect(html).toContain("secret://providers/...");
|
||||||
|
expect(html).not.toContain("api.example.test");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render raw key field names", () => {
|
it("does not render raw key field names", () => {
|
||||||
const html = renderToStaticMarkup(<AiProvidersPage />);
|
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], listState: "ready", source: "api" }} />);
|
||||||
|
|
||||||
expect(html).not.toContain("apiKey=");
|
expect(html).not.toContain("apiKey=");
|
||||||
expect(html).not.toContain("rawApiKey");
|
expect(html).not.toContain("rawApiKey");
|
||||||
|
|||||||
@@ -1,88 +1,170 @@
|
|||||||
import { Candy, CheckCircle2, FlaskConical, Power, Sparkles, WandSparkles } from "lucide-react";
|
import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, UserRoundMinus } from "lucide-react";
|
||||||
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
|
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { AiProviderResponse, AiProviderStatus } from "../api/types";
|
import type { AiProviderResponse, AiProviderStatus } from "../api/types";
|
||||||
import { EmptyState } from "../components/StateViews";
|
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
|
||||||
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import {
|
import {
|
||||||
aiProviderToForm,
|
aiProviderToForm,
|
||||||
emptyAiProviderForm,
|
emptyAiProviderForm,
|
||||||
type AiProviderActionState,
|
type AiProviderActionState,
|
||||||
type AiProviderFilter,
|
type AiProviderFilter,
|
||||||
type AiProviderFormState,
|
type AiProviderFormState,
|
||||||
summarizeAiProviders,
|
type AiProviderListState,
|
||||||
type AiProviderViewState
|
type AiProviderPageInitialState,
|
||||||
|
type AiProviderViewState,
|
||||||
|
summarizeAiProviders
|
||||||
} from "../contracts/aiProviders";
|
} from "../contracts/aiProviders";
|
||||||
import { aiProviderCreateRequestFromForm, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
|
import { aiProviderCreateRequestFromForm, aiProviderRetireRequest, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
|
|
||||||
const seedProviders: AiProviderResponse[] = [
|
interface AiProvidersPageProps extends Partial<PageComponentProps> {
|
||||||
|
initialState?: AiProviderPageInitialState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
draft: Partial<AiProviderFormState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string };
|
||||||
|
|
||||||
|
const providerPresets: ProviderPreset[] = [
|
||||||
{
|
{
|
||||||
|
id: "openai-relay",
|
||||||
|
label: "OpenAI Relay",
|
||||||
|
summary: "平台中转,适合公网 OpenAI 兼容网关。",
|
||||||
|
draft: {
|
||||||
id: "ai.openai",
|
id: "ai.openai",
|
||||||
name: "OpenAI Relay",
|
name: "OpenAI Relay",
|
||||||
kind: "openai-compatible",
|
kind: "openai-compatible",
|
||||||
baseUrl: "https://relay.example.test/v1",
|
baseUrl: "https://api.openai.com/v1",
|
||||||
apiKeyRef: "secret://providers/openai",
|
apiKeyRef: "secret://providers/openai",
|
||||||
models: ["gpt-4.1", "gpt-4.1-mini"],
|
modelsText: "gpt-4.1, gpt-4.1-mini",
|
||||||
defaultModel: "gpt-4.1-mini",
|
defaultModel: "gpt-4.1-mini",
|
||||||
relayMode: "relay",
|
relayMode: "relay",
|
||||||
timeoutMs: 30000,
|
|
||||||
status: "active",
|
|
||||||
redactionPolicy: "default"
|
redactionPolicy: "default"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ai.local",
|
id: "claude-relay",
|
||||||
name: "Local Ollama",
|
label: "Claude Relay",
|
||||||
|
summary: "平台托管 Anthropic 兼容配置,密钥只留引用。",
|
||||||
|
draft: {
|
||||||
|
id: "ai.claude",
|
||||||
|
name: "Claude Relay",
|
||||||
|
kind: "claude",
|
||||||
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
|
apiKeyRef: "secret://providers/anthropic",
|
||||||
|
modelsText: "claude-sonnet, claude-haiku",
|
||||||
|
defaultModel: "claude-sonnet",
|
||||||
|
relayMode: "relay",
|
||||||
|
redactionPolicy: "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-relay",
|
||||||
|
label: "Gemini Relay",
|
||||||
|
summary: "平台托管 Google Gemini 配置,保存后发现模型。",
|
||||||
|
draft: {
|
||||||
|
id: "ai.gemini",
|
||||||
|
name: "Gemini Relay",
|
||||||
|
kind: "gemini",
|
||||||
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
apiKeyRef: "secret://providers/gemini",
|
||||||
|
modelsText: "gemini-pro",
|
||||||
|
defaultModel: "gemini-pro",
|
||||||
|
relayMode: "relay",
|
||||||
|
redactionPolicy: "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ollama-local",
|
||||||
|
label: "Ollama Local",
|
||||||
|
summary: "本机或内网模型服务,默认走本地模式。",
|
||||||
|
draft: {
|
||||||
|
id: "ai.ollama",
|
||||||
|
name: "Ollama Local",
|
||||||
kind: "ollama",
|
kind: "ollama",
|
||||||
baseUrl: "http://127.0.0.1:11434/v1",
|
baseUrl: "http://127.0.0.1:11434/v1",
|
||||||
apiKeyRef: "env://OLLAMA_API_KEY",
|
apiKeyRef: "secret://providers/ollama-local",
|
||||||
models: ["llama3.1", "qwen2.5-coder"],
|
modelsText: "llama3.1, qwen2.5",
|
||||||
defaultModel: "qwen2.5-coder",
|
defaultModel: "llama3.1",
|
||||||
relayMode: "local",
|
relayMode: "local",
|
||||||
timeoutMs: 20000,
|
redactionPolicy: "default"
|
||||||
status: "disabled",
|
}
|
||||||
redactionPolicy: "local"
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AiProvidersPage() {
|
export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||||
const [providers, setProviders] = useState<AiProviderResponse[]>(seedProviders);
|
const initialSelectedProvider = initialState?.selectedId ? initialState.providers?.find((provider) => provider.id === initialState.selectedId) : undefined;
|
||||||
const [selectedId, setSelectedId] = useState(seedProviders[0]?.id ?? "");
|
const [providers, setProviders] = useState<AiProviderResponse[]>(initialState?.providers ?? []);
|
||||||
|
const [listState, setListState] = useState<AiProviderListState>(initialState?.listState ?? "loading");
|
||||||
|
const [listError, setListError] = useState(initialState?.listError ?? "");
|
||||||
|
const [selectedId, setSelectedId] = useState(initialState?.selectedId ?? "");
|
||||||
const [filter, setFilter] = useState<AiProviderFilter>("all");
|
const [filter, setFilter] = useState<AiProviderFilter>("all");
|
||||||
const [form, setForm] = useState<AiProviderFormState>(() => aiProviderToForm(seedProviders[0]));
|
const [form, setForm] = useState<AiProviderFormState>(() => aiProviderToForm(initialSelectedProvider));
|
||||||
const [viewState, setViewState] = useState<AiProviderViewState>("local");
|
const [formMode, setFormMode] = useState<"create" | "edit" | null>(initialSelectedProvider ? "edit" : null);
|
||||||
const [action, setAction] = useState<AiProviderActionState | null>(null);
|
const [viewState, setViewState] = useState<AiProviderViewState>(initialState?.source ?? "local-development");
|
||||||
|
const [action, setAction] = useState<AiProviderActionState | null>(initialState?.action ?? null);
|
||||||
|
const [confirmRetire, setConfirmRetire] = useState<AiProviderResponse | null>(null);
|
||||||
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
|
const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
|
||||||
|
const [formCheck, setFormCheck] = useState<FormCheckState | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
if (initialState?.providers) {
|
||||||
|
setListState(initialState.listState ?? "ready");
|
||||||
|
setViewState(initialState.source ?? "local-development");
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
platformApiClient
|
platformApiClient
|
||||||
.listAiProviders()
|
.listAiProviders()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (cancelled || response.items.length === 0) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const nextSelectedId =
|
||||||
|
initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId)
|
||||||
|
? initialState.selectedId
|
||||||
|
: "";
|
||||||
|
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
|
||||||
setProviders(response.items);
|
setProviders(response.items);
|
||||||
setSelectedId(response.items[0].id);
|
setSelectedId(nextSelectedId);
|
||||||
setForm(aiProviderToForm(response.items[0]));
|
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
|
||||||
|
setFormMode(nextSelectedProvider ? "edit" : null);
|
||||||
|
setListState("ready");
|
||||||
|
setListError("");
|
||||||
setViewState("api");
|
setViewState("api");
|
||||||
|
setFormCheck(null);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error: unknown) => {
|
||||||
if (!cancelled) {
|
if (cancelled) {
|
||||||
setViewState("local");
|
return;
|
||||||
}
|
}
|
||||||
|
setProviders([]);
|
||||||
|
setSelectedId("");
|
||||||
|
setForm(emptyAiProviderForm());
|
||||||
|
setListState("error");
|
||||||
|
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
|
||||||
|
setViewState("error");
|
||||||
|
setFormCheck(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [initialState?.listState, initialState?.providers, initialState?.selectedId, initialState?.source]);
|
||||||
|
|
||||||
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
|
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
|
||||||
const filteredProviders = useMemo(
|
const filteredProviders = useMemo(() => providers.filter((provider) => filter === "all" || provider.status === filter), [filter, providers]);
|
||||||
() => providers.filter((provider) => filter === "all" || provider.status === filter),
|
|
||||||
[filter, providers]
|
|
||||||
);
|
|
||||||
const selectedProvider = providers.find((provider) => provider.id === selectedId);
|
|
||||||
|
|
||||||
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
|
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
@@ -90,18 +172,71 @@ export function AiProvidersPage() {
|
|||||||
|
|
||||||
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
||||||
updateForm(event.target.name as keyof AiProviderFormState, event.target.value);
|
updateForm(event.target.name as keyof AiProviderFormState, event.target.value);
|
||||||
|
setFormCheck(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectProvider(provider: AiProviderResponse) {
|
function selectProvider(provider: AiProviderResponse) {
|
||||||
setSelectedId(provider.id);
|
setSelectedId(provider.id);
|
||||||
setForm(aiProviderToForm(provider));
|
setForm(aiProviderToForm(provider));
|
||||||
|
setFormMode("edit");
|
||||||
setAction(null);
|
setAction(null);
|
||||||
|
setFormCheck(null);
|
||||||
|
setExpandedProviderId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCreate() {
|
function startCreate() {
|
||||||
setSelectedId("");
|
setSelectedId("");
|
||||||
setForm(emptyAiProviderForm());
|
setForm(emptyAiProviderForm());
|
||||||
|
setFormMode("create");
|
||||||
setAction(null);
|
setAction(null);
|
||||||
|
setConfirmRetire(null);
|
||||||
|
setFormCheck(null);
|
||||||
|
setExpandedProviderId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
setFormMode(null);
|
||||||
|
setFormCheck(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProviderPreset(preset: ProviderPreset) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
...preset.draft,
|
||||||
|
id: formMode === "edit" ? current.id : preset.draft.id ?? current.id
|
||||||
|
}));
|
||||||
|
setFormCheck({ status: "pending", message: `已套用 ${preset.label} 预设。保存前请确认 Base URL、secret 引用和默认模型。` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFormPreflight() {
|
||||||
|
const missing: string[] = [];
|
||||||
|
const models = form.modelsText
|
||||||
|
.split(",")
|
||||||
|
.map((model) => model.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!form.id.trim()) {
|
||||||
|
missing.push("ID");
|
||||||
|
}
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
missing.push("名称");
|
||||||
|
}
|
||||||
|
if (!form.baseUrl.trim()) {
|
||||||
|
missing.push("Base URL");
|
||||||
|
}
|
||||||
|
if (!form.apiKeyRef.trim().startsWith("secret://providers/")) {
|
||||||
|
missing.push("secret://providers/... 密钥引用");
|
||||||
|
}
|
||||||
|
if (models.length === 0) {
|
||||||
|
missing.push("至少一个模型");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(Number.parseInt(form.timeoutMs, 10)) || Number.parseInt(form.timeoutMs, 10) <= 0) {
|
||||||
|
missing.push("有效超时");
|
||||||
|
}
|
||||||
|
setFormCheck(
|
||||||
|
missing.length > 0
|
||||||
|
? { status: "failed", message: `保存前检查未通过:请补充 ${missing.join("、")}。` }
|
||||||
|
: { status: "succeeded", message: "保存前检查通过。保存后先测试配置,测试成功再启用。" }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
@@ -117,46 +252,103 @@ export function AiProvidersPage() {
|
|||||||
setSelectedId(saved.id);
|
setSelectedId(saved.id);
|
||||||
setForm(aiProviderToForm(saved));
|
setForm(aiProviderToForm(saved));
|
||||||
setViewState("api");
|
setViewState("api");
|
||||||
setAction({ providerId: saved.id, label: "save", success: true, message: "saved" });
|
setListState("ready");
|
||||||
} catch {
|
setListError("");
|
||||||
const local = localProviderFromForm(form, existing ? selectedProvider?.status ?? "active" : "active");
|
setAction({ providerId: saved.id, label: "save", success: true, message: "已保存" });
|
||||||
upsertProvider(local);
|
setFormCheck(null);
|
||||||
setSelectedId(local.id);
|
setFormMode(null);
|
||||||
setForm(aiProviderToForm(local));
|
} catch (error) {
|
||||||
setViewState("local");
|
setViewState("error");
|
||||||
setAction({ providerId: local.id, label: "save", success: true, message: "saved locally" });
|
const message = errorMessage(error, "保存失败");
|
||||||
|
setAction({ providerId: form.id.trim() || selectedId, label: "save", success: false, message });
|
||||||
|
setFormCheck({ status: "failed", message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStatus(provider: AiProviderResponse) {
|
async function handleStatus(provider: AiProviderResponse) {
|
||||||
|
setExpandedProviderId(null);
|
||||||
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
|
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
|
||||||
try {
|
try {
|
||||||
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
|
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
|
||||||
upsertProvider(updated);
|
upsertProvider(updated);
|
||||||
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status });
|
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status === "disabled" ? "已停用" : "已启用" });
|
||||||
} catch {
|
} catch (error) {
|
||||||
const updated = { ...provider, status: nextStatus };
|
setAction({ providerId: provider.id, label: "status", success: false, message: errorMessage(error, "状态更新失败") });
|
||||||
upsertProvider(updated);
|
}
|
||||||
setAction({ providerId: provider.id, label: "status", success: true, message: nextStatus });
|
}
|
||||||
|
|
||||||
|
async function handleRetire(provider: AiProviderResponse) {
|
||||||
|
setConfirmBusy(true);
|
||||||
|
try {
|
||||||
|
const retired = await platformApiClient.setAiProviderStatus(provider.id, aiProviderRetireRequest());
|
||||||
|
upsertProvider(retired);
|
||||||
|
setAction({ providerId: provider.id, label: "retire", success: true, message: "已退役" });
|
||||||
|
setConfirmRetire(null);
|
||||||
|
} catch (error) {
|
||||||
|
setAction({ providerId: provider.id, label: "retire", success: false, message: errorMessage(error, "退役失败,可能仍被引用") });
|
||||||
|
} finally {
|
||||||
|
setConfirmBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTest(provider: AiProviderResponse) {
|
async function handleTest(provider: AiProviderResponse) {
|
||||||
|
setExpandedProviderId(null);
|
||||||
try {
|
try {
|
||||||
const result = await platformApiClient.testAiProvider(provider.id);
|
const result = await platformApiClient.testAiProvider(provider.id);
|
||||||
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
|
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
|
||||||
} catch {
|
} catch (error) {
|
||||||
const success = provider.status === "active";
|
setAction({ providerId: provider.id, label: "test", success: false, message: errorMessage(error, "测试失败") });
|
||||||
setAction({ providerId: provider.id, label: "test", success, message: success ? "metadata validation passed" : "provider must be active" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModels(provider: AiProviderResponse) {
|
async function handleModels(provider: AiProviderResponse) {
|
||||||
|
setExpandedProviderId(null);
|
||||||
try {
|
try {
|
||||||
const result = await platformApiClient.listAiProviderModels(provider.id);
|
const result = await platformApiClient.listAiProviderModels(provider.id);
|
||||||
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length}` });
|
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length} 个模型` });
|
||||||
} catch {
|
} catch (error) {
|
||||||
setAction({ providerId: provider.id, label: "models", success: true, message: `${provider.models.length}` });
|
setAction({ providerId: provider.id, label: "models", success: false, message: errorMessage(error, "模型刷新失败") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavedFormTest() {
|
||||||
|
const providerId = form.id.trim() || selectedId;
|
||||||
|
if (formMode !== "edit" || !providerId) {
|
||||||
|
setFormCheck({ status: "failed", message: "请先保存提供商,再测试已保存配置。" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormCheck({ status: "pending", message: "正在测试已保存配置…" });
|
||||||
|
try {
|
||||||
|
const result = await platformApiClient.testAiProvider(providerId);
|
||||||
|
setAction({ providerId, label: "test", success: result.success, message: result.message });
|
||||||
|
setFormCheck({ status: result.success ? "succeeded" : "failed", message: result.success ? `${result.message}。测试成功后可在更多菜单启用。` : result.message });
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error, "测试失败");
|
||||||
|
setAction({ providerId, label: "test", success: false, message });
|
||||||
|
setFormCheck({ status: "failed", message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDiscoverModelsForForm() {
|
||||||
|
const providerId = form.id.trim() || selectedId;
|
||||||
|
if (formMode !== "edit" || !providerId) {
|
||||||
|
setFormCheck({ status: "failed", message: "请先保存提供商,再从平台发现模型。" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormCheck({ status: "pending", message: "正在发现模型…" });
|
||||||
|
try {
|
||||||
|
const result = await platformApiClient.listAiProviderModels(providerId);
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
modelsText: result.models.join(", "),
|
||||||
|
defaultModel: result.defaultModel ?? result.models[0] ?? current.defaultModel
|
||||||
|
}));
|
||||||
|
setAction({ providerId, label: "models", success: true, message: `${result.models.length} 个模型` });
|
||||||
|
setFormCheck({ status: "succeeded", message: `已填入 ${result.models.length} 个模型;保存后生效。` });
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error, "模型发现失败");
|
||||||
|
setAction({ providerId, label: "models", success: false, message });
|
||||||
|
setFormCheck({ status: "failed", message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,23 +368,23 @@ export function AiProvidersPage() {
|
|||||||
AI 提供商管理
|
AI 提供商管理
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewState === "api" ? "已连接" : viewState === "saving" ? "保存中" : "本地视图"}</span>
|
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewStateLabel(viewState)}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="metric-grid ai-provider-metrics">
|
<dl className="page-summary-strip ai-provider-summary" aria-label="AI 提供商快速状态">
|
||||||
<article className="metric-card metric-tone-neutral">
|
<div className="page-summary-chip summary-tone-neutral">
|
||||||
<span className="metric-label">提供商</span>
|
<dt>提供商</dt>
|
||||||
<strong className="metric-value">{metrics.total}</strong>
|
<dd>{metrics.total}</dd>
|
||||||
</article>
|
|
||||||
<article className="metric-card metric-tone-success">
|
|
||||||
<span className="metric-label">启用</span>
|
|
||||||
<strong className="metric-value">{metrics.active}</strong>
|
|
||||||
</article>
|
|
||||||
<article className="metric-card metric-tone-warning">
|
|
||||||
<span className="metric-label">模型</span>
|
|
||||||
<strong className="metric-value">{metrics.models}</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="page-summary-chip summary-tone-success">
|
||||||
|
<dt>启用</dt>
|
||||||
|
<dd>{metrics.active}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="page-summary-chip summary-tone-warning">
|
||||||
|
<dt>模型</dt>
|
||||||
|
<dd>{metrics.models}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<div className="ai-provider-toolbar" aria-label="provider filters">
|
<div className="ai-provider-toolbar" aria-label="provider filters">
|
||||||
{(["all", "active", "disabled", "error"] as AiProviderFilter[]).map((item) => (
|
{(["all", "active", "disabled", "error"] as AiProviderFilter[]).map((item) => (
|
||||||
@@ -205,8 +397,16 @@ export function AiProvidersPage() {
|
|||||||
<span>新增</span>
|
<span>新增</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{action && <ResultBadge status={action.success ? "succeeded" : "failed"} label={action.message} />}
|
||||||
|
|
||||||
<div className="ai-provider-workspace">
|
{listState === "loading" && <LoadingState label="正在加载 AI 提供商…" />}
|
||||||
|
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => window.location.reload()} />}
|
||||||
|
|
||||||
|
{listState === "ready" && providers.length === 0 && (
|
||||||
|
<EmptyState icon={<FlaskConical size={26} />} title="暂无 AI 提供商" description="平台还没有返回任何提供商。创建第一个平台托管的 AI 提供商后,这里会显示 API 数据。" actionLabel="新增提供商" onAction={startCreate} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listState === "ready" && providers.length > 0 && (
|
||||||
<div className="provider-table-wrap">
|
<div className="provider-table-wrap">
|
||||||
<table className="provider-table">
|
<table className="provider-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -238,50 +438,83 @@ export function AiProvidersPage() {
|
|||||||
<td>
|
<td>
|
||||||
<code className="secret-ref">{provider.apiKeyRef}</code>
|
<code className="secret-ref">{provider.apiKeyRef}</code>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="provider-actions-cell">
|
||||||
<div className="row-actions" aria-label={`${provider.name} 操作`}>
|
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
|
||||||
<button type="button" title="启用或禁用" aria-label={`${provider.name} 启用或禁用`} onClick={() => void handleStatus(provider)}>
|
<button type="button" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
|
||||||
<Power size={15} />
|
|
||||||
</button>
|
|
||||||
<button type="button" title="测试配置" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
|
|
||||||
<FlaskConical size={15} />
|
<FlaskConical size={15} />
|
||||||
|
<span>测试</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="刷新模型" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
|
<button type="button" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
|
||||||
<Sparkles size={15} />
|
<Sparkles size={15} />
|
||||||
|
<span>模型</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
|
||||||
|
<WandSparkles size={15} />
|
||||||
|
<span>编辑</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(expandedProviderId === provider.id && "row-action-button-active")}
|
||||||
|
aria-expanded={expandedProviderId === provider.id}
|
||||||
|
aria-label={`${provider.name} 更多操作`}
|
||||||
|
onClick={() => setExpandedProviderId((current) => (current === provider.id ? null : provider.id))}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={15} />
|
||||||
|
<span>更多</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{expandedProviderId === provider.id && (
|
||||||
|
<div className="inline-action-menu" role="menu" aria-label={`${provider.name} 更多操作`}>
|
||||||
|
<button type="button" role="menuitem" onClick={() => void handleStatus(provider)}>
|
||||||
|
<Power size={14} />
|
||||||
|
<span>{provider.status === "active" ? "停用提供商" : "启用提供商"}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="danger-command"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedProviderId(null);
|
||||||
|
setConfirmRetire(provider);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserRoundMinus size={14} />
|
||||||
|
<span>退役提供商</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{filteredProviders.length === 0 && (
|
{filteredProviders.length === 0 && <EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />}
|
||||||
<EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="provider-form" onSubmit={(event) => void handleSubmit(event)}>
|
|
||||||
<div className="form-header">
|
|
||||||
<h2>{selectedProvider ? "编辑提供商" : "新增提供商"}</h2>
|
|
||||||
{action && (
|
|
||||||
<span className={cx("action-result", action.success ? "action-result-success" : "action-result-failed")}>
|
|
||||||
<CheckCircle2 size={14} />
|
|
||||||
{action.message}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={closeForm}>
|
||||||
|
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
|
||||||
|
<ProviderSetupGuide />
|
||||||
|
<div className="provider-preset-grid" aria-label="提供商预设">
|
||||||
|
{providerPresets.map((preset) => (
|
||||||
|
<button key={preset.id} type="button" className="provider-preset-option" onClick={() => applyProviderPreset(preset)}>
|
||||||
|
<strong>{preset.label}</strong>
|
||||||
|
<span>{preset.summary}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
ID
|
<span>ID</span>
|
||||||
<input name="id" value={form.id} onChange={handleInput} disabled={Boolean(selectedProvider)} />
|
<input name="id" value={form.id} onChange={handleInput} disabled={formMode === "edit"} />
|
||||||
|
<small className="field-help">稳定逻辑 ID,例如 ai.openai;编辑已有提供商时不可修改。</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
名称
|
<span>名称</span>
|
||||||
<input name="name" value={form.name} onChange={handleInput} />
|
<input name="name" value={form.name} onChange={handleInput} />
|
||||||
</label>
|
</label>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label>
|
<label>
|
||||||
类型
|
<span>类型</span>
|
||||||
<select name="kind" value={form.kind} onChange={handleInput}>
|
<select name="kind" value={form.kind} onChange={handleInput}>
|
||||||
<option value="openai-compatible">OpenAI Compatible</option>
|
<option value="openai-compatible">OpenAI Compatible</option>
|
||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
@@ -292,7 +525,7 @@ export function AiProvidersPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
模式
|
<span>模式</span>
|
||||||
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
|
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
|
||||||
<option value="direct">Direct</option>
|
<option value="direct">Direct</option>
|
||||||
<option value="relay">Relay</option>
|
<option value="relay">Relay</option>
|
||||||
@@ -301,48 +534,103 @@ export function AiProvidersPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
Base URL
|
<span>Base URL</span>
|
||||||
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
|
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
|
||||||
|
<small className="field-help">优先用预设填入;为空或测试失败时不要启用。</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
密钥引用
|
<span>密钥引用</span>
|
||||||
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
|
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
|
||||||
|
<small className="field-help">只填写 secret://providers/... 引用,不要粘贴 raw API key。</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
模型
|
<span>模型</span>
|
||||||
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
|
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
|
||||||
|
<small className="field-help">逗号分隔;编辑已保存配置时可以自动发现并填入。</small>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label>
|
<label>
|
||||||
默认模型
|
<span>默认模型</span>
|
||||||
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
|
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
超时 ms
|
<span>超时 ms</span>
|
||||||
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
|
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
脱敏策略
|
<span>脱敏策略</span>
|
||||||
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
|
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
|
||||||
|
<small className="field-help">默认 default 会隐藏密钥、Bearer token 和敏感配置片段。</small>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className="primary-command" title="保存提供商">
|
<div className="form-helper-actions">
|
||||||
<WandSparkles size={16} />
|
<button type="button" className="theme-upload" onClick={runFormPreflight}>
|
||||||
<span>保存</span>
|
保存前检查
|
||||||
|
</button>
|
||||||
|
<button type="button" className="theme-upload" disabled={formMode !== "edit"} onClick={() => void handleSavedFormTest()}>
|
||||||
|
{formMode === "edit" ? "测试已保存配置" : "保存后可测试"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="theme-upload" disabled={formMode !== "edit"} onClick={() => void handleDiscoverModelsForForm()}>
|
||||||
|
{formMode === "edit" ? "发现模型并填入" : "保存后可发现模型"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
{formCheck && <ResultBadge status={formCheck.status} label={formCheck.message} />}
|
||||||
|
<div className="confirm-actions">
|
||||||
|
<button type="button" onClick={closeForm}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="confirm-primary">
|
||||||
|
<WandSparkles size={16} />
|
||||||
|
<span>保存配置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ManagementDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmRetire !== null}
|
||||||
|
title="退役 AI 提供商"
|
||||||
|
description={`确认将 ${confirmRetire?.name ?? ""}(${confirmRetire?.id ?? ""})退役为停用状态?如果仍被引用,平台会拒绝这次变更。`}
|
||||||
|
confirmLabel="确认退役"
|
||||||
|
danger
|
||||||
|
busy={confirmBusy}
|
||||||
|
onCancel={() => setConfirmRetire(null)}
|
||||||
|
onConfirm={() => void (confirmRetire ? handleRetire(confirmRetire) : undefined)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function localProviderFromForm(form: AiProviderFormState, status: AiProviderStatus): AiProviderResponse {
|
function ProviderSetupGuide() {
|
||||||
const request = aiProviderCreateRequestFromForm(form);
|
return (
|
||||||
return {
|
<div className="form-guidance provider-setup-guide">
|
||||||
...request,
|
<strong>配置流程</strong>
|
||||||
defaultModel: request.defaultModel,
|
<span>先选预设,再填写平台侧 secret 引用。保存前做本地检查;保存后测试已保存配置,测试成功后再启用。</span>
|
||||||
status
|
</div>
|
||||||
};
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (error.message.toLowerCase().includes("reference")) {
|
||||||
|
return `${fallback}:${error.message}`;
|
||||||
|
}
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewStateLabel(viewState: AiProviderViewState): string {
|
||||||
|
switch (viewState) {
|
||||||
|
case "api":
|
||||||
|
return "平台 API";
|
||||||
|
case "local-development":
|
||||||
|
return "本地开发";
|
||||||
|
case "saving":
|
||||||
|
return "保存中";
|
||||||
|
default:
|
||||||
|
return "连接失败";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterLabel(filter: AiProviderFilter): string {
|
function filterLabel(filter: AiProviderFilter): string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { HomePage } from "./HomePage";
|
import { HomePage } from "./HomePage";
|
||||||
|
import { MaintenancePage } from "./MaintenancePage";
|
||||||
import { PluginsPage } from "./PluginsPage";
|
import { PluginsPage } from "./PluginsPage";
|
||||||
import { ProfileSettingsPage } from "./ProfileSettingsPage";
|
import { ProfileSettingsPage } from "./ProfileSettingsPage";
|
||||||
import { ServerDetailPage } from "./ServerDetailPage";
|
import { ServerDetailPage } from "./ServerDetailPage";
|
||||||
@@ -10,6 +11,7 @@ import { UsersPage } from "./UsersPage";
|
|||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
|
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
|
||||||
import type { OperationTracker } from "../stores/operations";
|
import type { OperationTracker } from "../stores/operations";
|
||||||
|
import type { UserResponse } from "../api/types";
|
||||||
|
|
||||||
const adminUser: CurrentUserView = {
|
const adminUser: CurrentUserView = {
|
||||||
id: "user-admin",
|
id: "user-admin",
|
||||||
@@ -30,6 +32,17 @@ const noopOperations: OperationTracker = {
|
|||||||
isPending: () => false
|
isPending: () => false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reviewUser: UserResponse = {
|
||||||
|
id: "user-reviewer",
|
||||||
|
displayName: "Plugin Reviewer",
|
||||||
|
email: "reviewer@example.test",
|
||||||
|
status: "pending",
|
||||||
|
roles: ["server-admin"],
|
||||||
|
profile: { contactNote: "needs approval" },
|
||||||
|
createdAt: "2026-07-03T00:00:00Z",
|
||||||
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
|
};
|
||||||
|
|
||||||
function pageProps(params: PageComponentProps["params"] = {}): PageComponentProps {
|
function pageProps(params: PageComponentProps["params"] = {}): PageComponentProps {
|
||||||
return {
|
return {
|
||||||
session: adminUser,
|
session: adminUser,
|
||||||
@@ -86,11 +99,22 @@ describe("first-party console pages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders user access review context", () => {
|
it("renders user access review context", () => {
|
||||||
const html = renderToStaticMarkup(<UsersPage {...pageProps()} />);
|
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [reviewUser], loading: false, source: "local-development" }} />);
|
||||||
|
|
||||||
expect(html).toContain("用户管理");
|
expect(html).toContain("用户管理");
|
||||||
expect(html).toContain("Plugin Reviewer");
|
expect(html).toContain("Plugin Reviewer");
|
||||||
expect(html).toContain("needs approval");
|
expect(html).toContain("needs approval");
|
||||||
|
expect(html).toContain("本地开发样例 / 禁止假成功");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders maintenance triage entry points", () => {
|
||||||
|
const html = renderToStaticMarkup(<MaintenancePage {...pageProps()} />);
|
||||||
|
|
||||||
|
expect(html).toContain("系统维护");
|
||||||
|
expect(html).toContain("维护排障入口");
|
||||||
|
expect(html).toContain("节点详情");
|
||||||
|
expect(html).toContain("最近失败任务");
|
||||||
|
expect(html).toContain("审计异常");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders profile settings as a full page", () => {
|
it("renders profile settings as a full page", () => {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Sparkles, WandSparkles } from "lucide-react";
|
import { Activity, ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { AuditEventResponse, RunEndpointResponse } from "../api/types";
|
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
|
|
||||||
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||||
|
|
||||||
export function MaintenancePage() {
|
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
|
||||||
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
|
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
|
||||||
const [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
|
const [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
|
||||||
|
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
|
||||||
|
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
|
||||||
|
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||||
|
|
||||||
const refreshEndpoints = useCallback(async () => {
|
const refreshEndpoints = useCallback(async () => {
|
||||||
setEndpoints({ status: "loading" });
|
setEndpoints({ status: "loading" });
|
||||||
@@ -32,10 +36,71 @@ export function MaintenancePage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshJobs = useCallback(async () => {
|
||||||
|
setJobs({ status: "loading" });
|
||||||
|
try {
|
||||||
|
const response = await platformApiClient.listJobs();
|
||||||
|
setJobs({ status: "ready", data: response.items });
|
||||||
|
} catch (error) {
|
||||||
|
setJobs({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshServers = useCallback(async () => {
|
||||||
|
setServers({ status: "loading" });
|
||||||
|
try {
|
||||||
|
const response = await platformApiClient.listServerInstances();
|
||||||
|
setServers({ status: "ready", data: response.items });
|
||||||
|
} catch (error) {
|
||||||
|
setServers({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshAll = useCallback(() => {
|
||||||
void refreshEndpoints();
|
void refreshEndpoints();
|
||||||
void refreshEvents();
|
void refreshEvents();
|
||||||
}, [refreshEndpoints, refreshEvents]);
|
void refreshJobs();
|
||||||
|
void refreshServers();
|
||||||
|
}, [refreshEndpoints, refreshEvents, refreshJobs, refreshServers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshAll();
|
||||||
|
}, [refreshAll]);
|
||||||
|
|
||||||
|
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
|
||||||
|
const eventItems = events.status === "ready" ? events.data : [];
|
||||||
|
const jobItems = jobs.status === "ready" ? jobs.data : [];
|
||||||
|
const serverItems = servers.status === "ready" ? servers.data : [];
|
||||||
|
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
|
||||||
|
const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
|
||||||
|
const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]);
|
||||||
|
const failedAuditCount = eventItems.filter((event) => event.result !== "success").length;
|
||||||
|
const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length;
|
||||||
|
|
||||||
|
async function retryJob(job: JobResponse) {
|
||||||
|
const retryStamp = Date.now();
|
||||||
|
const operationId = operations.begin({ intent: "重试任务", targetKind: "platform", targetId: job.id, requester: session.displayName });
|
||||||
|
setTriageResult({ status: "pending", label: `正在重试 ${job.id}` });
|
||||||
|
try {
|
||||||
|
const retried = await platformApiClient.createJob({
|
||||||
|
id: `${job.id}-retry-${retryStamp}`,
|
||||||
|
serverInstanceId: job.serverInstanceId,
|
||||||
|
runEndpointId: job.runEndpointId,
|
||||||
|
capability: job.capability,
|
||||||
|
targetKey: job.targetKey,
|
||||||
|
inputRef: job.inputRef,
|
||||||
|
idempotencyKey: `web-retry-${job.id}-${retryStamp}`,
|
||||||
|
progress: { percent: 0, message: `retry of ${job.id}` }
|
||||||
|
});
|
||||||
|
operations.succeed(operationId, `已创建重试任务:${retried.id}`, retried);
|
||||||
|
setTriageResult({ status: "succeeded", label: `已创建重试任务 ${retried.id}` });
|
||||||
|
void refreshJobs();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "任务重试失败";
|
||||||
|
operations.fail(operationId, message);
|
||||||
|
setTriageResult({ status: "failed", label: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="maintenance-page">
|
<div className="maintenance-page">
|
||||||
@@ -46,19 +111,40 @@ export function MaintenancePage() {
|
|||||||
<WandSparkles size={22} style={{ verticalAlign: "-3px" }} /> 系统维护
|
<WandSparkles size={22} style={{ verticalAlign: "-3px" }} /> 系统维护
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" className="icon-command" onClick={refreshAll}>
|
||||||
type="button"
|
|
||||||
className="icon-command"
|
|
||||||
onClick={() => {
|
|
||||||
void refreshEndpoints();
|
|
||||||
void refreshEvents();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
<span>刷新</span>
|
<span>刷新</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="form-guidance maintenance-triage-intro">
|
||||||
|
<strong>维护排障入口</strong>
|
||||||
|
<span>从节点详情、最近失败任务和审计异常进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="maintenance-triage-grid" aria-label="维护排障入口">
|
||||||
|
<button type="button" className="triage-card" onClick={() => void refreshEndpoints()}>
|
||||||
|
<ServerCog size={18} />
|
||||||
|
<span>节点详情</span>
|
||||||
|
<strong>{endpoints.status === "ready" ? `${unhealthyEndpointCount} 个需关注` : "加载中"}</strong>
|
||||||
|
<small>查看心跳、容量、能力标签、相关服务器和日志链路。</small>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="triage-card" onClick={() => void refreshJobs()}>
|
||||||
|
<ListChecks size={18} />
|
||||||
|
<span>最近失败任务</span>
|
||||||
|
<strong>{jobs.status === "ready" ? `${failedJobs.length} 个失败` : "加载中"}</strong>
|
||||||
|
<small>从失败任务进入重试、查看相关服务器和查看日志链路。</small>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="triage-card" onClick={() => void refreshEvents()}>
|
||||||
|
<Activity size={18} />
|
||||||
|
<span>审计异常</span>
|
||||||
|
<strong>{events.status === "ready" ? `${failedAuditCount} 条异常` : "加载中"}</strong>
|
||||||
|
<small>按资源定位失败操作和平台拒绝原因。</small>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
||||||
|
|
||||||
<section className="console-panel" aria-label="run endpoints">
|
<section className="console-panel" aria-label="run endpoints">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>运行节点</h2>
|
<h2>运行节点</h2>
|
||||||
@@ -67,26 +153,104 @@ export function MaintenancePage() {
|
|||||||
{endpoints.status === "error" && (
|
{endpoints.status === "error" && (
|
||||||
<ErrorState title="运行节点加载失败" reason={endpoints.reason} diagnosticId="maintenance-endpoints" onRetry={() => void refreshEndpoints()} compact />
|
<ErrorState title="运行节点加载失败" reason={endpoints.reason} diagnosticId="maintenance-endpoints" onRetry={() => void refreshEndpoints()} compact />
|
||||||
)}
|
)}
|
||||||
|
{servers.status === "error" && <ErrorState title="相关服务器加载失败" reason={servers.reason} diagnosticId="maintenance-servers" onRetry={() => void refreshServers()} compact />}
|
||||||
{endpoints.status === "ready" && endpoints.data.length === 0 && (
|
{endpoints.status === "ready" && endpoints.data.length === 0 && (
|
||||||
<EmptyState title="暂无运行节点" description="还没有运行端注册到平台。" actionLabel="刷新" onAction={() => void refreshEndpoints()} />
|
<EmptyState title="暂无运行节点" description="还没有运行端注册到平台。" actionLabel="刷新" onAction={() => void refreshEndpoints()} />
|
||||||
)}
|
)}
|
||||||
{endpoints.status === "ready" && endpoints.data.length > 0 && (
|
{endpoints.status === "ready" && endpoints.data.length > 0 && (
|
||||||
<div className="resource-list">
|
<div className="resource-list maintenance-node-list">
|
||||||
{endpoints.data.map((endpoint) => (
|
{endpoints.data.map((endpoint) => {
|
||||||
<article key={endpoint.id} className="resource-list-item">
|
const relatedServers = serverItems.filter((server) => server.runEndpointId === endpoint.id);
|
||||||
|
const firstRelatedServer = relatedServers[0];
|
||||||
|
return (
|
||||||
|
<article key={endpoint.id} className="resource-list-item maintenance-node-item">
|
||||||
<div>
|
<div>
|
||||||
<strong>{endpoint.displayName}</strong>
|
<strong>{endpoint.displayName}</strong>
|
||||||
<span className="provider-id">{endpoint.id}</span>
|
<span className="provider-id">{endpoint.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={cx("status-pill", endpoint.status === "online" ? "status-active" : endpoint.status === "offline" ? "status-error" : "status-disabled")}>
|
<span className={cx("status-pill", endpointStatusClass(endpoint))}>{endpointStatusLabel(endpoint)}</span>
|
||||||
{endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status}
|
|
||||||
</span>
|
|
||||||
<span>
|
<span>
|
||||||
任务 {endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs}(排队 {endpoint.capacity.queuedJobs})
|
任务 {endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs}(排队 {endpoint.capacity.queuedJobs})
|
||||||
</span>
|
</span>
|
||||||
<span>心跳 {new Date(endpoint.lastHeartbeatAt).toLocaleString()}</span>
|
<span>{heartbeatReason(endpoint)}</span>
|
||||||
|
<details className="node-detail">
|
||||||
|
<summary>节点详情</summary>
|
||||||
|
<span>版本 {endpoint.version}</span>
|
||||||
|
<span>能力 {endpoint.capabilities.slice(0, 4).join(" / ") || "未上报"}</span>
|
||||||
|
<span>相关服务器 {relatedServers.length}</span>
|
||||||
|
</details>
|
||||||
|
<div className="maintenance-actions">
|
||||||
|
<button type="button" className="theme-upload" onClick={() => void refreshEndpoints()}>
|
||||||
|
刷新节点
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="theme-upload"
|
||||||
|
disabled={!firstRelatedServer}
|
||||||
|
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
|
||||||
|
>
|
||||||
|
查看相关服务器
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="theme-upload"
|
||||||
|
disabled={!firstRelatedServer}
|
||||||
|
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
|
||||||
|
>
|
||||||
|
查看日志链路
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="console-panel" aria-label="failed jobs">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>最近失败任务</h2>
|
||||||
|
</div>
|
||||||
|
{jobs.status === "loading" && <LoadingState label="正在加载任务…" compact />}
|
||||||
|
{jobs.status === "error" && <ErrorState title="任务加载失败" reason={jobs.reason} diagnosticId="maintenance-jobs" onRetry={() => void refreshJobs()} compact />}
|
||||||
|
{jobs.status === "ready" && failedJobs.length === 0 && (
|
||||||
|
<EmptyState title="暂无失败任务" description="最近任务没有失败记录;如果节点异常,请先查看运行节点心跳和容量。" actionLabel="刷新任务" onAction={() => void refreshJobs()} />
|
||||||
|
)}
|
||||||
|
{jobs.status === "ready" && failedJobs.length > 0 && (
|
||||||
|
<div className="operation-list">
|
||||||
|
{failedJobs.map((job) => {
|
||||||
|
const server = job.serverInstanceId ? serverById.get(job.serverInstanceId) : undefined;
|
||||||
|
const endpoint = endpointById.get(job.runEndpointId);
|
||||||
|
return (
|
||||||
|
<div key={job.id} className="operation-item">
|
||||||
|
<div className="operation-item-head">
|
||||||
|
<strong>{job.capability}</strong>
|
||||||
|
<span className="status-pill status-error">{jobStateLabel(job.state)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="operation-meta">
|
||||||
|
<span>
|
||||||
|
任务 <code>{job.id}</code>
|
||||||
|
</span>
|
||||||
|
<span>服务器 {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
|
||||||
|
<span>节点 {endpoint ? endpoint.displayName : job.runEndpointId}</span>
|
||||||
|
<span>{progressMessage(job)}</span>
|
||||||
|
<span>{formatTimestamp(job.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="maintenance-actions">
|
||||||
|
<button type="button" className="theme-upload" onClick={() => void retryJob(job)}>
|
||||||
|
<RotateCcw size={13} />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<button type="button" className="theme-upload" disabled={!job.serverInstanceId} onClick={() => job.serverInstanceId && onNavigate("serverDetail", { serverId: job.serverInstanceId })}>
|
||||||
|
查看相关服务器
|
||||||
|
</button>
|
||||||
|
<button type="button" className="theme-upload" disabled={!job.serverInstanceId} onClick={() => job.serverInstanceId && onNavigate("serverDetail", { serverId: job.serverInstanceId })}>
|
||||||
|
查看日志链路
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -108,7 +272,7 @@ export function MaintenancePage() {
|
|||||||
<div key={event.id} className="operation-item">
|
<div key={event.id} className="operation-item">
|
||||||
<div className="operation-item-head">
|
<div className="operation-item-head">
|
||||||
<strong>{event.summary || `${event.action} ${event.resourceKind}`}</strong>
|
<strong>{event.summary || `${event.action} ${event.resourceKind}`}</strong>
|
||||||
<span className={cx("status-pill", event.result === "success" ? "status-active" : "status-error")}>{event.result}</span>
|
<span className={cx("status-pill", auditResultClass(event))}>{event.result}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="operation-meta">
|
<div className="operation-meta">
|
||||||
<span>
|
<span>
|
||||||
@@ -118,7 +282,20 @@ export function MaintenancePage() {
|
|||||||
<span>
|
<span>
|
||||||
资源 {event.resourceKind}/{event.resourceId}
|
资源 {event.resourceKind}/{event.resourceId}
|
||||||
</span>
|
</span>
|
||||||
<span>{new Date(event.createdAt).toLocaleString()}</span>
|
<span>{formatTimestamp(event.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="maintenance-actions">
|
||||||
|
<button type="button" className="theme-upload" onClick={() => void refreshEvents()}>
|
||||||
|
查看审计链路
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="theme-upload"
|
||||||
|
disabled={event.resourceKind !== "server-instance"}
|
||||||
|
onClick={() => event.resourceKind === "server-instance" && onNavigate("serverDetail", { serverId: event.resourceId })}
|
||||||
|
>
|
||||||
|
查看相关服务器
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -128,3 +305,75 @@ export function MaintenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function endpointStatusClass(endpoint: RunEndpointResponse): string {
|
||||||
|
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) <= 5) {
|
||||||
|
return "status-active";
|
||||||
|
}
|
||||||
|
if (endpoint.status === "offline") {
|
||||||
|
return "status-error";
|
||||||
|
}
|
||||||
|
return "status-disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointStatusLabel(endpoint: RunEndpointResponse): string {
|
||||||
|
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5) {
|
||||||
|
return "心跳延迟";
|
||||||
|
}
|
||||||
|
return endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heartbeatReason(endpoint: RunEndpointResponse): string {
|
||||||
|
const age = heartbeatAgeMinutes(endpoint.lastHeartbeatAt);
|
||||||
|
if (!Number.isFinite(age)) {
|
||||||
|
return `心跳时间异常:${endpoint.lastHeartbeatAt}`;
|
||||||
|
}
|
||||||
|
if (endpoint.status === "offline") {
|
||||||
|
return `心跳异常:节点离线,最后 ${formatTimestamp(endpoint.lastHeartbeatAt)}`;
|
||||||
|
}
|
||||||
|
if (age > 5) {
|
||||||
|
return `心跳异常:${Math.round(age)} 分钟未更新`;
|
||||||
|
}
|
||||||
|
if (endpoint.capacity.runningJobs >= endpoint.capacity.maxJobs) {
|
||||||
|
return "容量已满:等待任务会继续排队";
|
||||||
|
}
|
||||||
|
return `心跳正常:${formatTimestamp(endpoint.lastHeartbeatAt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heartbeatAgeMinutes(value: string): number {
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
if (!Number.isFinite(timestamp)) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
return (Date.now() - timestamp) / 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditResultClass(event: AuditEventResponse): string {
|
||||||
|
return event.result === "success" ? "status-active" : "status-error";
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobStateLabel(state: JobResponse["state"]): string {
|
||||||
|
switch (state) {
|
||||||
|
case "queued":
|
||||||
|
return "排队";
|
||||||
|
case "accepted":
|
||||||
|
return "已接收";
|
||||||
|
case "running":
|
||||||
|
return "运行中";
|
||||||
|
case "succeeded":
|
||||||
|
return "成功";
|
||||||
|
case "cancelled":
|
||||||
|
return "已取消";
|
||||||
|
default:
|
||||||
|
return "失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressMessage(job: JobResponse): string {
|
||||||
|
return job.progress.message ? `原因 ${job.progress.message}` : `进度 ${job.progress.percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ describe("PluginsPage", () => {
|
|||||||
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "loading" }} />);
|
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "loading" }} />);
|
||||||
|
|
||||||
expect(html).toContain("插件市场");
|
expect(html).toContain("插件市场");
|
||||||
|
expect(html).toContain("page-summary-chip");
|
||||||
|
expect(html).not.toContain("metric-card");
|
||||||
expect(html).toContain("正在加载插件市场");
|
expect(html).toContain("正在加载插件市场");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ describe("PluginsPage", () => {
|
|||||||
expect(html).toContain("安装");
|
expect(html).toContain("安装");
|
||||||
expect(html).toContain("启用");
|
expect(html).toContain("启用");
|
||||||
expect(html).toContain("停用");
|
expect(html).toContain("停用");
|
||||||
|
expect(html).toContain('role="dialog"');
|
||||||
expect(html).not.toContain("billing");
|
expect(html).not.toContain("billing");
|
||||||
expect(html).not.toContain("/Users/");
|
expect(html).not.toContain("/Users/");
|
||||||
expect(html).not.toContain("unix://");
|
expect(html).not.toContain("unix://");
|
||||||
@@ -71,4 +74,12 @@ describe("PluginsPage", () => {
|
|||||||
expect(html).toContain("本地演示数据");
|
expect(html).toContain("本地演示数据");
|
||||||
expect(html).toContain("本地演示数据仅用于前端开发");
|
expect(html).toContain("本地演示数据仅用于前端开发");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not open a plugin detail pane until a plugin is selected", () => {
|
||||||
|
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "ready", plugins: [marketplacePlugin] }} />);
|
||||||
|
|
||||||
|
expect(html).toContain("Example Server");
|
||||||
|
expect(html).not.toContain('role="dialog"');
|
||||||
|
expect(html).not.toContain("manifest validated");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from "rea
|
|||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { GamePluginStatus, MarketplacePluginFilterRequest, MarketplacePluginResponse, MarketplacePluginStateAction } from "../api/types";
|
import type { GamePluginStatus, MarketplacePluginFilterRequest, MarketplacePluginResponse, MarketplacePluginStateAction } from "../api/types";
|
||||||
|
import { ManagementDialog } from "../components/OperationControls";
|
||||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import { PageFrame } from "../components/PageFrame";
|
import { PageFrame } from "../components/PageFrame";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import { pluginCatalog } from "../contracts/shell";
|
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
|
|
||||||
type ListState = "loading" | "ready" | "error";
|
type ListState = "loading" | "ready" | "error";
|
||||||
@@ -59,28 +59,28 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
|
|||||||
serverType: serverType.trim() || undefined,
|
serverType: serverType.trim() || undefined,
|
||||||
capability: capability.trim() || undefined
|
capability: capability.trim() || undefined
|
||||||
};
|
};
|
||||||
|
if (initialState?.plugins) {
|
||||||
|
setListState(initialState.listState ?? "ready");
|
||||||
|
setListError(initialState.listError ?? "");
|
||||||
|
setPlugins(initialState.plugins);
|
||||||
|
setSelectedId(initialState.selectedId ?? "");
|
||||||
|
setDetail(initialState.detail ?? null);
|
||||||
|
setUsingFallback(initialState.usingFallback ?? false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await platformApiClient.listMarketplacePlugins(filter);
|
const response = await platformApiClient.listMarketplacePlugins(filter);
|
||||||
setPlugins(response.items);
|
setPlugins(response.items);
|
||||||
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : response.items[0]?.id || ""));
|
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : ""));
|
||||||
setUsingFallback(false);
|
setUsingFallback(false);
|
||||||
setListState("ready");
|
setListState("ready");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const fallback = fallbackMarketplacePlugins(filter);
|
|
||||||
setPlugins(fallback);
|
|
||||||
setSelectedId((current) => (current && fallback.some((plugin) => plugin.id === current) ? current : fallback[0]?.id || ""));
|
|
||||||
setUsingFallback(true);
|
|
||||||
setListState("ready");
|
|
||||||
setListError("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setListState("error");
|
setListState("error");
|
||||||
setListError(error instanceof Error ? error.message : "插件市场加载失败");
|
setListError(error instanceof Error ? error.message : "插件市场加载失败");
|
||||||
setPlugins([]);
|
setPlugins([]);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
}
|
}
|
||||||
}, [capability, keyword, serverType, statusFilter]);
|
}, [capability, initialState, keyword, serverType, statusFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -248,13 +248,13 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedId && (
|
<ManagementDialog open={selectedId !== ""} title={detail?.name ?? "插件详情"} wide onClose={() => { setSelectedId(""); setDetail(null); setDetailError(""); }}>
|
||||||
<section className="drawer-panel plugin-detail-panel" aria-label="plugin marketplace detail">
|
<div className="plugin-detail-panel" aria-label="plugin marketplace detail">
|
||||||
{detailPending && <LoadingState label="正在加载插件详情…" compact />}
|
{detailPending && <LoadingState label="正在加载插件详情…" compact />}
|
||||||
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} compact />}
|
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} compact />}
|
||||||
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback} onAction={(action) => void changeState(action)} />}
|
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback} onAction={(action) => void changeState(action)} />}
|
||||||
</section>
|
</div>
|
||||||
)}
|
</ManagementDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -326,43 +326,6 @@ function DetailStat({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackMarketplacePlugins(filter: MarketplacePluginFilterRequest): MarketplacePluginResponse[] {
|
|
||||||
const keyword = filter.keyword?.trim().toLowerCase() ?? "";
|
|
||||||
return pluginCatalog
|
|
||||||
.map((plugin) => ({
|
|
||||||
id: plugin.id,
|
|
||||||
name: plugin.name,
|
|
||||||
description: plugin.validation,
|
|
||||||
version: plugin.version,
|
|
||||||
serverType: plugin.serverType,
|
|
||||||
serverDisplayName: plugin.serverType,
|
|
||||||
supportedOs: ["linux"],
|
|
||||||
manifestRef: `manifest://${plugin.id}/${plugin.version}`,
|
|
||||||
createFormSchemaRef: "schemas/create-form.schema.json",
|
|
||||||
capabilities: ["process.install", "process.start", "logs.read"],
|
|
||||||
declaredPermissions: plugin.permissions,
|
|
||||||
permissions: {
|
|
||||||
ai: plugin.permissions.includes("ai.invoke"),
|
|
||||||
logs: plugin.permissions.includes("server.logs.read"),
|
|
||||||
files: plugin.permissions.some((permission) => permission.includes("files")),
|
|
||||||
jobs: plugin.permissions.includes("server.lifecycle"),
|
|
||||||
artifacts: plugin.permissions.some((permission) => permission.includes("artifacts"))
|
|
||||||
},
|
|
||||||
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
|
|
||||||
bridgeActions: plugin.bridgeActions,
|
|
||||||
pages: [{ key: "overview", title: "Overview", path: "/overview", permissions: plugin.permissions, bridgeActions: plugin.bridgeActions }],
|
|
||||||
tags: [plugin.serverType],
|
|
||||||
aiPurposes: plugin.permissions.includes("ai.invoke") ? ["logs.diagnose"] : [],
|
|
||||||
validationViolations: plugin.status === "invalid" ? [plugin.validation] : undefined,
|
|
||||||
status: plugin.status,
|
|
||||||
source: "local-development"
|
|
||||||
}))
|
|
||||||
.filter((plugin) => !filter.status || filter.status === "all" || plugin.status === filter.status)
|
|
||||||
.filter((plugin) => !filter.serverType || plugin.serverType === filter.serverType)
|
|
||||||
.filter((plugin) => !filter.capability || plugin.capabilities.includes(filter.capability) || plugin.bridgeActions.includes(filter.capability))
|
|
||||||
.filter((plugin) => !keyword || [plugin.id, plugin.name, plugin.serverType, ...plugin.tags, ...plugin.capabilities].some((value) => value.toLowerCase().includes(keyword)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function unique(values: string[]): string[] {
|
function unique(values: string[]): string[] {
|
||||||
return Array.from(new Set(values.filter(Boolean))).sort((left, right) => left.localeCompare(right));
|
return Array.from(new Set(values.filter(Boolean))).sort((left, right) => left.localeCompare(right));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Do not put shared API clients, shared DTOs, route definitions, or bridge contrac
|
|||||||
Pages must use the shared black-mecha / magical-girl visual system from `../theme/` instead of page-local card systems or unrelated palettes.
|
Pages must use the shared black-mecha / magical-girl visual system from `../theme/` instead of page-local card systems or unrelated palettes.
|
||||||
|
|
||||||
- Reuse shared shell, card, panel, table, drawer, dialog, status, command, log, diff, plugin group, and operation history classes from `theme/base.css`.
|
- Reuse shared shell, card, panel, table, drawer, dialog, status, command, log, diff, plugin group, and operation history classes from `theme/base.css`.
|
||||||
|
- Keep management lists, grids, and tables full-width. Create, edit, and detail workflows belong in modals, drawers, or detail routes, not in permanent right-side panes or inline form panels beside/above the list.
|
||||||
- Keep page surfaces translucent enough for the selected built-in or uploaded background desktop to remain visible.
|
- Keep page surfaces translucent enough for the selected built-in or uploaded background desktop to remain visible.
|
||||||
- Ambient magical particles are global shell chrome. Pages must not add fixed decorative sparkles, hearts, moons, snowflakes, sigils, or custom backdrop layers.
|
- Ambient magical particles are global shell chrome. Pages must not add fixed decorative sparkles, hearts, moons, snowflakes, sigils, or custom backdrop layers.
|
||||||
- Do not introduce opaque white cards, heavy dark dashboards, stock marketing layouts, or single-page custom gradients that bypass the theme tokens.
|
- Do not introduce opaque white cards, heavy dark dashboards, stock marketing layouts, or single-page custom gradients that bypass the theme tokens.
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
|
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses typed server metadata and archive APIs without local fallback mutation", () => {
|
||||||
|
expect(serverDetailPageSource).toContain("updateServerInstance(instance.id");
|
||||||
|
expect(serverDetailPageSource).toContain("archiveServerInstance(instance.id)");
|
||||||
|
expect(serverDetailPageSource).toContain("serverMetadataUpdateRequestFromForm");
|
||||||
|
expect(serverDetailPageSource).toContain("canArchiveServer(instance.state)");
|
||||||
|
expect(serverDetailPageSource).toContain("配置读取不可用");
|
||||||
|
expect(serverDetailPageSource).not.toContain("fallbackConfig");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not locally mutate visible config after dispatching approval jobs", () => {
|
it("does not locally mutate visible config after dispatching approval jobs", () => {
|
||||||
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
|
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
|
||||||
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
|
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
|
||||||
@@ -60,6 +69,26 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain("Bearer ");
|
expect(serverDetailPageSource).not.toContain("Bearer ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces run distribution and client-manager workflows through platform-mediated APIs", () => {
|
||||||
|
expect(serverDetailPageSource).toContain("getServerRuntimeActions");
|
||||||
|
expect(serverDetailPageSource).toContain("generateRunDistribution");
|
||||||
|
expect(serverDetailPageSource).toContain("downloadLatestRunDistribution");
|
||||||
|
expect(serverDetailPageSource).toContain("pushRunUpdate");
|
||||||
|
expect(serverDetailPageSource).toContain("generateClientManager");
|
||||||
|
expect(serverDetailPageSource).toContain("resetClientManagerKey");
|
||||||
|
expect(serverDetailPageSource).toContain("checkDependencies");
|
||||||
|
expect(serverDetailPageSource).toContain("installDependencies");
|
||||||
|
expect(serverDetailPageSource).toContain("listServerLiveLogs");
|
||||||
|
expect(serverDetailPageSource).toContain("requestLogBackfill");
|
||||||
|
expect(serverDetailPageSource).toContain("safeRuntimeRef");
|
||||||
|
expect(serverDetailPageSource).not.toContain("authKey");
|
||||||
|
expect(serverDetailPageSource).not.toContain("password=");
|
||||||
|
expect(serverDetailPageSource).not.toContain("unix://");
|
||||||
|
expect(serverDetailPageSource).not.toContain("tcp://");
|
||||||
|
expect(serverDetailPageSource).not.toContain("mysql://");
|
||||||
|
expect(serverDetailPageSource).not.toContain("sqlite://");
|
||||||
|
});
|
||||||
|
|
||||||
it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => {
|
it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => {
|
||||||
expect(serverDetailPageSource).toContain('action === "install" || action === "restart" || action === "status"');
|
expect(serverDetailPageSource).toContain('action === "install" || action === "restart" || action === "status"');
|
||||||
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop"');
|
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop"');
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
import { Archive, ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type {
|
import type {
|
||||||
ConfigDiffLineResponse,
|
ConfigDiffLineResponse,
|
||||||
ArtifactDownloadReferenceResponse,
|
ArtifactDownloadReferenceResponse,
|
||||||
ArtifactResponse,
|
ArtifactResponse,
|
||||||
|
ClientManagerDistributionResponse,
|
||||||
GamePluginResponse,
|
GamePluginResponse,
|
||||||
JobResponse,
|
JobResponse,
|
||||||
LogEntryBody,
|
LogEntryBody,
|
||||||
LogStreamResponse,
|
LogStreamResponse,
|
||||||
|
RunDistributionResponse,
|
||||||
ServerConfigDiffPreviewResponse,
|
ServerConfigDiffPreviewResponse,
|
||||||
ServerConfigResponse,
|
ServerConfigResponse,
|
||||||
ServerInstanceResponse,
|
ServerInstanceResponse,
|
||||||
ServerMemberResponse,
|
ServerMemberResponse,
|
||||||
ServerMetricsResponse
|
ServerMetricsResponse,
|
||||||
|
ServerRuntimeActionsResponse
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||||
import { canStartServer, canStopServer, pluginLabel } from "../contracts/serverManagement";
|
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||||
import {
|
import {
|
||||||
serverDetailSections,
|
serverDetailSections,
|
||||||
serverIsOnline,
|
serverIsOnline,
|
||||||
@@ -30,7 +33,16 @@ import {
|
|||||||
type PluginControlGroupView,
|
type PluginControlGroupView,
|
||||||
type ServerDetailSection
|
type ServerDetailSection
|
||||||
} from "../contracts/workspace";
|
} from "../contracts/workspace";
|
||||||
import { serverLifecycleCommandRequest } from "../schemas/serverManagement";
|
import {
|
||||||
|
clientManagerBuildRequest,
|
||||||
|
dependencyJobRequest,
|
||||||
|
logBackfillRequest,
|
||||||
|
runDistributionGenerateRequest,
|
||||||
|
runUpdateRequest,
|
||||||
|
serverArchiveConfirmation,
|
||||||
|
serverLifecycleCommandRequest,
|
||||||
|
serverMetadataUpdateRequestFromForm
|
||||||
|
} from "../schemas/serverManagement";
|
||||||
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
|
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
|
||||||
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
|
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
@@ -38,7 +50,6 @@ import { stateLabel, statusClass } from "./ServersPage";
|
|||||||
|
|
||||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||||
|
|
||||||
const fallbackConfig = "# server.properties\nmax-players=20\nmotd=Welcome to the server\npvp=true\nview-distance=8\n";
|
|
||||||
const defaultConfigKey = "server.properties";
|
const defaultConfigKey = "server.properties";
|
||||||
|
|
||||||
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
|
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
|
||||||
@@ -49,6 +60,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
|||||||
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
||||||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||||||
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
|
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
|
||||||
|
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
|
||||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
|
|
||||||
@@ -59,14 +71,19 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
|||||||
}
|
}
|
||||||
setInstance({ status: "loading" });
|
setInstance({ status: "loading" });
|
||||||
try {
|
try {
|
||||||
const [detail, pluginResponse, jobResponse] = await Promise.all([
|
const [detail, pluginResponse, jobResponse, runtimeResponse] = await Promise.all([
|
||||||
platformApiClient.getServerInstance(serverId),
|
platformApiClient.getServerInstance(serverId),
|
||||||
platformApiClient.listGamePlugins(),
|
platformApiClient.listGamePlugins(),
|
||||||
platformApiClient.listJobs(serverId)
|
platformApiClient.listJobs(serverId),
|
||||||
|
platformApiClient
|
||||||
|
.getServerRuntimeActions(serverId)
|
||||||
|
.then((data): LoadState<ServerRuntimeActionsResponse> => ({ status: "ready", data }))
|
||||||
|
.catch((error): LoadState<ServerRuntimeActionsResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" }))
|
||||||
]);
|
]);
|
||||||
setInstance({ status: "ready", data: detail });
|
setInstance({ status: "ready", data: detail });
|
||||||
setPlugins(pluginResponse.items);
|
setPlugins(pluginResponse.items);
|
||||||
setJobs(jobResponse.items);
|
setJobs(jobResponse.items);
|
||||||
|
setRuntimeActions(runtimeResponse);
|
||||||
const artifactLists = await Promise.all(
|
const artifactLists = await Promise.all(
|
||||||
jobResponse.items.slice(0, 20).map((job) =>
|
jobResponse.items.slice(0, 20).map((job) =>
|
||||||
platformApiClient
|
platformApiClient
|
||||||
@@ -79,6 +96,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||||
setArtifacts([]);
|
setArtifacts([]);
|
||||||
|
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const metricsResponse = await platformApiClient.listServerMetrics();
|
const metricsResponse = await platformApiClient.listServerMetrics();
|
||||||
@@ -224,6 +242,25 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
|
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
|
||||||
|
{section === "overview" && (
|
||||||
|
<RuntimeDistributionSection
|
||||||
|
instance={instance.data}
|
||||||
|
runtimeActions={runtimeActions}
|
||||||
|
session={session}
|
||||||
|
operations={operations}
|
||||||
|
onOpenLogs={() => setSection("logs")}
|
||||||
|
onChanged={() => void refresh()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{section === "overview" && (
|
||||||
|
<ServerMetadataSection
|
||||||
|
instance={instance.data}
|
||||||
|
session={session}
|
||||||
|
operations={operations}
|
||||||
|
onChanged={(next) => setInstance({ status: "ready", data: next })}
|
||||||
|
onArchived={() => onNavigate("servers")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
|
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
|
||||||
{section === "logs" && <LogsSection serverId={serverId} />}
|
{section === "logs" && <LogsSection serverId={serverId} />}
|
||||||
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||||
@@ -264,6 +301,99 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
|
|||||||
return [...byID.values()];
|
return [...byID.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ServerMetadataSectionProps {
|
||||||
|
instance: ServerInstanceResponse;
|
||||||
|
session: PageComponentProps["session"];
|
||||||
|
operations: PageComponentProps["operations"];
|
||||||
|
onChanged: (instance: ServerInstanceResponse) => void;
|
||||||
|
onArchived: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerMetadataSection({ instance, session, operations, onChanged, onArchived }: ServerMetadataSectionProps) {
|
||||||
|
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
|
||||||
|
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||||||
|
const [confirmArchive, setConfirmArchive] = useState<ReturnType<typeof serverArchiveConfirmation> | null>(null);
|
||||||
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(serverMetadataFormFromInstance(instance));
|
||||||
|
}, [instance.id, instance.name]);
|
||||||
|
|
||||||
|
async function saveMetadata(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const operationId = operations.begin({ intent: "更新服务器信息", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||||
|
setResult({ status: "pending", label: "正在保存服务器信息" });
|
||||||
|
try {
|
||||||
|
const updated = await platformApiClient.updateServerInstance(instance.id, serverMetadataUpdateRequestFromForm(draft));
|
||||||
|
onChanged(updated);
|
||||||
|
operations.succeed(operationId, `服务器信息已更新:${updated.id}`);
|
||||||
|
setResult({ status: "succeeded", label: `已更新 ${updated.name}` });
|
||||||
|
} catch (error) {
|
||||||
|
operations.fail(operationId, error instanceof Error ? error.message : "服务器信息更新失败");
|
||||||
|
setResult({ status: "failed", label: error instanceof Error ? error.message : "服务器信息更新失败" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveServer() {
|
||||||
|
setConfirmBusy(true);
|
||||||
|
const operationId = operations.begin({ intent: "归档服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||||
|
try {
|
||||||
|
await platformApiClient.archiveServerInstance(instance.id);
|
||||||
|
operations.succeed(operationId, `服务器已归档:${instance.id}`);
|
||||||
|
setResult({ status: "succeeded", label: `${instance.name} 已归档` });
|
||||||
|
setConfirmArchive(null);
|
||||||
|
onArchived();
|
||||||
|
} catch (error) {
|
||||||
|
operations.fail(operationId, error instanceof Error ? error.message : "服务器归档失败");
|
||||||
|
setResult({ status: "failed", label: error instanceof Error ? error.message : "归档失败,平台拒绝当前状态" });
|
||||||
|
} finally {
|
||||||
|
setConfirmBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="console-panel" aria-label="server metadata">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>
|
||||||
|
<Pencil size={16} style={{ verticalAlign: "-2px" }} /> 基本信息
|
||||||
|
</h2>
|
||||||
|
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||||
|
</div>
|
||||||
|
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveMetadata(event)}>
|
||||||
|
<label>
|
||||||
|
显示名称
|
||||||
|
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<div className="action-strip">
|
||||||
|
<button type="submit" className="primary-command" disabled={draft.name.trim() === instance.name}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
<span>保存名称</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-command danger-command" disabled={!canArchiveServer(instance.state)} onClick={() => setConfirmArchive(serverArchiveConfirmation(instance))}>
|
||||||
|
<Archive size={14} />
|
||||||
|
<span>归档</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{!canArchiveServer(instance.state) && (
|
||||||
|
<p className="provider-id" style={{ marginTop: 10 }}>
|
||||||
|
运行中、安装中或已归档的服务器不能直接归档;请先停止或等待状态稳定。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmArchive !== null}
|
||||||
|
title="归档服务器"
|
||||||
|
description={`确认归档 ${confirmArchive?.name ?? ""}(${confirmArchive?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
|
||||||
|
confirmLabel="确认归档"
|
||||||
|
danger
|
||||||
|
busy={confirmBusy}
|
||||||
|
onCancel={() => setConfirmArchive(null)}
|
||||||
|
onConfirm={() => void archiveServer()}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ServerAdministratorsSectionProps {
|
interface ServerAdministratorsSectionProps {
|
||||||
instance: ServerInstanceResponse;
|
instance: ServerInstanceResponse;
|
||||||
session: PageComponentProps["session"];
|
session: PageComponentProps["session"];
|
||||||
@@ -451,6 +581,362 @@ function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RuntimeDistributionSectionProps {
|
||||||
|
instance: ServerInstanceResponse;
|
||||||
|
runtimeActions: LoadState<ServerRuntimeActionsResponse>;
|
||||||
|
session: PageComponentProps["session"];
|
||||||
|
operations: PageComponentProps["operations"];
|
||||||
|
onOpenLogs: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuntimeDistributionSection({ instance, runtimeActions, session, operations, onOpenLogs, onChanged }: RuntimeDistributionSectionProps) {
|
||||||
|
const defaults = runtimeDefaultsForPlugin(instance.pluginId);
|
||||||
|
const [targetOs, setTargetOs] = useState(defaults.runOs);
|
||||||
|
const [targetArch, setTargetArch] = useState("amd64");
|
||||||
|
const [profileKey, setProfileKey] = useState(defaults.clientProfileKey);
|
||||||
|
const [repositoryUrl, setRepositoryUrl] = useState(defaults.repositoryUrl);
|
||||||
|
const [sourceRevision, setSourceRevision] = useState(defaults.sourceRevision);
|
||||||
|
const [probeKey, setProbeKey] = useState(defaults.probeKey);
|
||||||
|
const [installPlanKey, setInstallPlanKey] = useState(defaults.installPlanKey);
|
||||||
|
const [logSourceKey, setLogSourceKey] = useState(defaults.logSourceKey);
|
||||||
|
const [checkpointRef, setCheckpointRef] = useState("");
|
||||||
|
const [lastRun, setLastRun] = useState<RunDistributionResponse | null>(null);
|
||||||
|
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
|
||||||
|
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
|
||||||
|
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||||||
|
|
||||||
|
const actionByKey = useMemo(() => {
|
||||||
|
if (runtimeActions.status !== "ready") {
|
||||||
|
return new Map<string, { available: boolean; reason?: string }>();
|
||||||
|
}
|
||||||
|
return new Map(runtimeActions.data.actions.map((action) => [action.key, { available: action.available, reason: action.reason }]));
|
||||||
|
}, [runtimeActions]);
|
||||||
|
|
||||||
|
function canUse(key: string): boolean {
|
||||||
|
return actionByKey.get(key)?.available ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonFor(key: string): string {
|
||||||
|
return actionByKey.get(key)?.reason ?? "平台暂未开放该操作";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOperation<T>(intent: string, execute: () => Promise<T>, summarize: (value: T) => string) {
|
||||||
|
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
|
||||||
|
setResult({ status: "pending", label: `${intent} 执行中` });
|
||||||
|
try {
|
||||||
|
const value = await execute();
|
||||||
|
const label = summarize(value);
|
||||||
|
operations.succeed(operationId, label);
|
||||||
|
setResult({ status: "succeeded", label });
|
||||||
|
onChanged();
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : `${intent} 失败`;
|
||||||
|
operations.fail(operationId, reason, operationId);
|
||||||
|
setResult({ status: "failed", label: reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestRunArtifact(): { artifactId: string; checksum?: string } | null {
|
||||||
|
if (lastRun) {
|
||||||
|
return { artifactId: lastRun.artifactId, checksum: lastRun.checksum };
|
||||||
|
}
|
||||||
|
if (lastDownload) {
|
||||||
|
return { artifactId: lastDownload.artifactId, checksum: lastDownload.checksum };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="console-panel" aria-label="run distribution controls">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>
|
||||||
|
<PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 运行分发
|
||||||
|
</h2>
|
||||||
|
{runtimeActions.status === "ready" ? (
|
||||||
|
<span className={cx("status-pill", runtimeActions.data.runStatus === "online" ? "status-active" : "status-disabled")}>
|
||||||
|
run {runtimeActions.data.runStatus}
|
||||||
|
</span>
|
||||||
|
) : runtimeActions.status === "error" ? (
|
||||||
|
<ResultBadge status="failed" label={runtimeActions.reason} />
|
||||||
|
) : (
|
||||||
|
<span className="page-status">读取中</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{result && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<ResultBadge status={result.status} label={result.label} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="provider-form" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>
|
||||||
|
run 平台
|
||||||
|
<select value={targetOs} onChange={(event) => setTargetOs(event.target.value)}>
|
||||||
|
<option value="linux">linux</option>
|
||||||
|
<option value="windows">windows</option>
|
||||||
|
<option value="darwin">darwin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
架构
|
||||||
|
<select value={targetArch} onChange={(event) => setTargetArch(event.target.value)}>
|
||||||
|
<option value="amd64">amd64</option>
|
||||||
|
<option value="arm64">arm64</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
客户端 profile
|
||||||
|
<input value={profileKey} onChange={(event) => setProfileKey(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
源仓库
|
||||||
|
<input value={repositoryUrl} onChange={(event) => setRepositoryUrl(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
revision
|
||||||
|
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
依赖 probe
|
||||||
|
<input value={probeKey} onChange={(event) => setProbeKey(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
安装 plan
|
||||||
|
<input value={installPlanKey} onChange={(event) => setInstallPlanKey(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
日志源
|
||||||
|
<input value={logSourceKey} onChange={(event) => setLogSourceKey(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="plugin-group-body">
|
||||||
|
<RuntimeActionRow
|
||||||
|
title="run 包"
|
||||||
|
description={`生成 ${targetOs}/${targetArch} run。包内含当前密钥,界面只显示 generation、artifact 和 secret ref。`}
|
||||||
|
disabled={!canUse("generate-run")}
|
||||||
|
reason={reasonFor("generate-run")}
|
||||||
|
actionLabel="生成 run"
|
||||||
|
onAction={() =>
|
||||||
|
void runOperation(
|
||||||
|
"生成 run",
|
||||||
|
async () => {
|
||||||
|
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
|
||||||
|
setLastRun(distribution);
|
||||||
|
return distribution;
|
||||||
|
},
|
||||||
|
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 已生成,artifact ${distribution.artifactId},generation ${distribution.keyGeneration}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RuntimeActionRow
|
||||||
|
title="run 下载与更新"
|
||||||
|
description={lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
|
||||||
|
disabled={!canUse("download-run")}
|
||||||
|
reason={reasonFor("download-run")}
|
||||||
|
actionLabel="下载 run"
|
||||||
|
onAction={() =>
|
||||||
|
void runOperation(
|
||||||
|
"下载 run",
|
||||||
|
async () => {
|
||||||
|
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||||||
|
setLastDownload(reference);
|
||||||
|
return reference;
|
||||||
|
},
|
||||||
|
(reference) => `下载引用已创建,artifact ${reference.artifactId},有效期 ${new Date(reference.expiresAt).toLocaleTimeString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
secondaryLabel="推送更新"
|
||||||
|
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null}
|
||||||
|
secondaryReason={latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
|
||||||
|
onSecondary={() =>
|
||||||
|
void runOperation(
|
||||||
|
"推送 run 更新",
|
||||||
|
async () => {
|
||||||
|
const artifact = latestRunArtifact();
|
||||||
|
if (!artifact) {
|
||||||
|
throw new Error("请先生成或下载 run 包");
|
||||||
|
}
|
||||||
|
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
|
||||||
|
},
|
||||||
|
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RuntimeActionRow
|
||||||
|
title="run 密钥"
|
||||||
|
description="重置后旧 run 包会失效,必须重新生成并重新部署。"
|
||||||
|
disabled={!canUse("reset-run-key")}
|
||||||
|
reason={reasonFor("reset-run-key")}
|
||||||
|
actionLabel="重置 run 密钥"
|
||||||
|
danger
|
||||||
|
onAction={() =>
|
||||||
|
void runOperation(
|
||||||
|
"重置 run 密钥",
|
||||||
|
() => platformApiClient.resetRunKey(instance.id),
|
||||||
|
(key) => `run 密钥已重置,generation ${key.generation},fingerprint ${key.fingerprint}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RuntimeActionRow
|
||||||
|
title="客户端管理器"
|
||||||
|
description={lastClient ? `最近构建 ${lastClient.artifactId},generation ${lastClient.keyGeneration}` : "按插件声明的 profile 构建客户端管理器,使用独立密钥。"}
|
||||||
|
disabled={!canUse("generate-client-manager")}
|
||||||
|
reason={reasonFor("generate-client-manager")}
|
||||||
|
actionLabel="生成客户端"
|
||||||
|
onAction={() =>
|
||||||
|
void runOperation(
|
||||||
|
"生成客户端管理器",
|
||||||
|
async () => {
|
||||||
|
const distribution = await platformApiClient.generateClientManager(
|
||||||
|
instance.id,
|
||||||
|
clientManagerBuildRequest({ serverInstanceId: instance.id, profileKey, targetOs, targetArch, repositoryUrl, sourceRevision })
|
||||||
|
);
|
||||||
|
setLastClient(distribution);
|
||||||
|
return distribution;
|
||||||
|
},
|
||||||
|
(distribution) => `客户端管理器已生成,artifact ${distribution.artifactId},secret ref ${safeRuntimeRef(distribution.secretRef)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
secondaryLabel="下载客户端"
|
||||||
|
secondaryDisabled={!canUse("download-client-manager")}
|
||||||
|
secondaryReason={reasonFor("download-client-manager")}
|
||||||
|
onSecondary={() =>
|
||||||
|
void runOperation(
|
||||||
|
"下载客户端管理器",
|
||||||
|
() => platformApiClient.downloadLatestClientManager(instance.id, { profileKey }),
|
||||||
|
(reference) => `客户端下载引用已创建,artifact ${reference.artifactId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RuntimeActionRow
|
||||||
|
title="客户端密钥"
|
||||||
|
description="客户端管理器和 run 使用不同密钥。重置后旧客户端必须重新生成。"
|
||||||
|
disabled={!canUse("reset-client-manager-key")}
|
||||||
|
reason={reasonFor("reset-client-manager-key")}
|
||||||
|
actionLabel="重置客户端密钥"
|
||||||
|
danger
|
||||||
|
onAction={() =>
|
||||||
|
void runOperation(
|
||||||
|
"重置客户端密钥",
|
||||||
|
() => platformApiClient.resetClientManagerKey(instance.id, { componentKind: "client-manager", componentKey: profileKey }),
|
||||||
|
(key) => `客户端密钥已重置,generation ${key.generation},fingerprint ${key.fingerprint}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RuntimeActionRow
|
||||||
|
title="依赖"
|
||||||
|
description={`检查 ${probeKey},安装计划 ${installPlanKey || "未填写"}`}
|
||||||
|
disabled={!canUse("dependencies-check")}
|
||||||
|
reason={reasonFor("dependencies-check")}
|
||||||
|
actionLabel="依赖检查"
|
||||||
|
onAction={() =>
|
||||||
|
void runOperation(
|
||||||
|
"依赖检查",
|
||||||
|
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
|
||||||
|
(job) => `依赖检查任务已排队,job ${job.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
secondaryLabel="依赖安装"
|
||||||
|
secondaryDisabled={!canUse("dependencies-install") || !installPlanKey.trim()}
|
||||||
|
secondaryReason={!installPlanKey.trim() ? "请填写插件声明的 install plan" : reasonFor("dependencies-install")}
|
||||||
|
onSecondary={() =>
|
||||||
|
void runOperation(
|
||||||
|
"依赖安装",
|
||||||
|
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
|
||||||
|
(job) => `依赖安装任务已排队,job ${job.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RuntimeActionRow
|
||||||
|
title="日志"
|
||||||
|
description="实时日志来自平台日志 API,历史日志通过 backfill job 返回 cursor/ref。"
|
||||||
|
disabled={!canUse("live-logs")}
|
||||||
|
reason={reasonFor("live-logs")}
|
||||||
|
actionLabel="实时日志"
|
||||||
|
onAction={onOpenLogs}
|
||||||
|
secondaryLabel="历史回填"
|
||||||
|
secondaryDisabled={!canUse("historical-logs")}
|
||||||
|
secondaryReason={reasonFor("historical-logs")}
|
||||||
|
onSecondary={() =>
|
||||||
|
void runOperation(
|
||||||
|
"历史日志回填",
|
||||||
|
() => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)),
|
||||||
|
(job) => `历史日志回填任务已排队,job ${job.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
checkpoint ref
|
||||||
|
<input value={checkpointRef} placeholder="可选 artifact://logs/checkpoint/..." onChange={(event) => setCheckpointRef(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</RuntimeActionRow>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeActionRowProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
disabled: boolean;
|
||||||
|
reason: string;
|
||||||
|
actionLabel: string;
|
||||||
|
danger?: boolean;
|
||||||
|
onAction: () => void;
|
||||||
|
secondaryLabel?: string;
|
||||||
|
secondaryDisabled?: boolean;
|
||||||
|
secondaryReason?: string;
|
||||||
|
onSecondary?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuntimeActionRow({ title, description, disabled, reason, actionLabel, danger, onAction, secondaryLabel, secondaryDisabled, secondaryReason, onSecondary, children }: RuntimeActionRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="plugin-control-row">
|
||||||
|
<span>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<p>{description}</p>
|
||||||
|
{disabled && <span className="provider-id">不可用:{reason}</span>}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
<div className="action-strip">
|
||||||
|
<button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : actionLabel} onClick={onAction}>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
<span>{actionLabel}</span>
|
||||||
|
</button>
|
||||||
|
{secondaryLabel && onSecondary && (
|
||||||
|
<button type="button" className="icon-command" disabled={secondaryDisabled} title={secondaryDisabled ? secondaryReason : secondaryLabel} onClick={onSecondary}>
|
||||||
|
<Download size={14} />
|
||||||
|
<span>{secondaryLabel}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeDefaultsForPlugin(pluginId: string) {
|
||||||
|
const isScum = pluginId.toLowerCase().includes("scum");
|
||||||
|
return {
|
||||||
|
runOs: isScum ? "windows" : "linux",
|
||||||
|
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
|
||||||
|
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
|
||||||
|
sourceRevision: "main",
|
||||||
|
probeKey: isScum ? "steamcmd" : "java-21",
|
||||||
|
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
||||||
|
logSourceKey: isScum ? "server-log" : "latest-log"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRuntimeRef(ref: string): string {
|
||||||
|
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
return "[redacted-ref]";
|
||||||
|
}
|
||||||
|
|
||||||
interface LogsSectionProps {
|
interface LogsSectionProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
@@ -471,8 +957,8 @@ function LogsSection({ serverId }: LogsSectionProps) {
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setStreams({ status: "loading" });
|
setStreams({ status: "loading" });
|
||||||
try {
|
try {
|
||||||
const response = await platformApiClient.listLogStreams();
|
const response = await platformApiClient.listServerLiveLogs(serverId);
|
||||||
const serverStreams = response.items.filter((stream) => stream.serverInstanceId === serverId);
|
const serverStreams = response.items;
|
||||||
setStreams({ status: "ready", data: serverStreams });
|
setStreams({ status: "ready", data: serverStreams });
|
||||||
const collected: Array<LogEntryBody & { source: string }> = [];
|
const collected: Array<LogEntryBody & { source: string }> = [];
|
||||||
for (const stream of serverStreams) {
|
for (const stream of serverStreams) {
|
||||||
@@ -659,9 +1145,9 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
|||||||
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
|
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
|
||||||
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
|
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
|
||||||
setDraft(response.content);
|
setDraft(response.content);
|
||||||
} catch {
|
} catch (error) {
|
||||||
setConfig({ status: "ready", data: { content: fallbackConfig, source: "local" } });
|
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
|
||||||
setDraft(fallbackConfig);
|
setDraft("");
|
||||||
}
|
}
|
||||||
}, [serverId]);
|
}, [serverId]);
|
||||||
|
|
||||||
@@ -717,9 +1203,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
|||||||
<article className="console-panel" aria-label="server configuration">
|
<article className="console-panel" aria-label="server configuration">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>配置</h2>
|
<h2>配置</h2>
|
||||||
{config.status === "ready" && (
|
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}</span>}
|
||||||
<span className="page-status">{config.data.source === "api" ? `配置版本 v${instance.configVersion}` : "本地示例配置(配置读取接口未提供)"}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{writeOperation && (
|
{writeOperation && (
|
||||||
<div style={{ marginBottom: 10 }}>
|
<div style={{ marginBottom: 10 }}>
|
||||||
@@ -736,6 +1220,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
|
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
|
||||||
|
{config.status === "error" && <ErrorState title="配置读取不可用" reason={config.reason} diagnosticId={`server-config:${serverId}`} onRetry={() => void refresh()} compact />}
|
||||||
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
|
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
|
||||||
{config.status === "ready" && (
|
{config.status === "ready" && (
|
||||||
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
|
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
|
||||||
@@ -1125,7 +1610,7 @@ interface LlmSectionProps {
|
|||||||
|
|
||||||
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
|
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [currentConfig, setCurrentConfig] = useState<string>(fallbackConfig);
|
const [currentConfig, setCurrentConfig] = useState<string>("");
|
||||||
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -1140,7 +1625,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// keep the local fallback config
|
setCurrentConfig("");
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ import {
|
|||||||
type ServerCreateFormState
|
type ServerCreateFormState
|
||||||
} from "../contracts/serverManagement";
|
} from "../contracts/serverManagement";
|
||||||
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
|
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
|
||||||
import { serverCreateRequestFromForm } from "../schemas/serverManagement";
|
import {
|
||||||
|
clientManagerBuildRequest,
|
||||||
|
dependencyJobRequest,
|
||||||
|
logBackfillRequest,
|
||||||
|
runDistributionGenerateRequest,
|
||||||
|
runUpdateRequest,
|
||||||
|
serverCreateRequestFromForm
|
||||||
|
} from "../schemas/serverManagement";
|
||||||
import { isPlatformAdmin } from "../contracts/workspace";
|
import { isPlatformAdmin } from "../contracts/workspace";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
|
|
||||||
@@ -114,6 +121,56 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function handleQuickRuntimeAction(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction) {
|
||||||
|
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
|
||||||
|
const intent = quickRuntimeActionLabel(action);
|
||||||
|
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
|
||||||
|
try {
|
||||||
|
let message = "运行操作已提交";
|
||||||
|
if (action === "generate-run") {
|
||||||
|
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
|
||||||
|
message = `run 包已生成,artifact ${distribution.artifactId}`;
|
||||||
|
} else if (action === "download-run") {
|
||||||
|
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||||||
|
message = `run 下载引用已创建,artifact ${reference.artifactId}`;
|
||||||
|
} else if (action === "push-run-update") {
|
||||||
|
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||||||
|
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
|
||||||
|
message = `run 更新任务已排队,job ${update.jobId ?? update.id}`;
|
||||||
|
} else if (action === "generate-client-manager") {
|
||||||
|
const distribution = await platformApiClient.generateClientManager(
|
||||||
|
instance.id,
|
||||||
|
clientManagerBuildRequest({
|
||||||
|
serverInstanceId: instance.id,
|
||||||
|
profileKey: defaults.clientProfileKey,
|
||||||
|
targetOs: defaults.clientOs,
|
||||||
|
targetArch: "amd64",
|
||||||
|
repositoryUrl: defaults.repositoryUrl,
|
||||||
|
sourceRevision: "main"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
message = `客户端管理器已生成,artifact ${distribution.artifactId}`;
|
||||||
|
} else if (action === "dependencies-check") {
|
||||||
|
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
|
||||||
|
message = `依赖检查任务已排队,job ${job.id}`;
|
||||||
|
} else if (action === "dependencies-install") {
|
||||||
|
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
|
||||||
|
message = `依赖安装任务已排队,job ${job.id}`;
|
||||||
|
} else if (action === "live-logs") {
|
||||||
|
onNavigate("serverDetail", { serverId: instance.id });
|
||||||
|
message = "已打开服务器详情,可切换到日志页查看实时日志";
|
||||||
|
} else if (action === "historical-logs") {
|
||||||
|
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
|
||||||
|
message = `历史日志回填任务已排队,job ${job.id}`;
|
||||||
|
}
|
||||||
|
operations.succeed(operationId, message);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
operations.fail(operationId, error instanceof Error ? error.message : "运行操作失败", operationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器");
|
const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -240,7 +297,13 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
{visibleCards.length > 0 && (
|
{visibleCards.length > 0 && (
|
||||||
<div className="server-card-grid" aria-label="server list">
|
<div className="server-card-grid" aria-label="server list">
|
||||||
{visibleCards.map((card) => (
|
{visibleCards.map((card) => (
|
||||||
<ServerCard key={card.instance.id} card={card} metricsPending={metricsPending} onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })} />
|
<ServerCard
|
||||||
|
key={card.instance.id}
|
||||||
|
card={card}
|
||||||
|
metricsPending={metricsPending}
|
||||||
|
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
|
||||||
|
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -248,17 +311,28 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServerQuickRuntimeAction =
|
||||||
|
| "generate-run"
|
||||||
|
| "download-run"
|
||||||
|
| "push-run-update"
|
||||||
|
| "generate-client-manager"
|
||||||
|
| "dependencies-check"
|
||||||
|
| "dependencies-install"
|
||||||
|
| "live-logs"
|
||||||
|
| "historical-logs";
|
||||||
|
|
||||||
interface ServerCardProps {
|
interface ServerCardProps {
|
||||||
card: ServerCardView;
|
card: ServerCardView;
|
||||||
metricsPending: boolean;
|
metricsPending: boolean;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
|
onQuickAction: (action: ServerQuickRuntimeAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerCard({ card, metricsPending, onOpen }: ServerCardProps) {
|
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
|
||||||
const { instance, metrics, pendingJobs } = card;
|
const { instance, metrics, pendingJobs } = card;
|
||||||
const online = serverIsOnline(instance.state);
|
const online = serverIsOnline(instance.state);
|
||||||
return (
|
return (
|
||||||
<button type="button" className="server-card" onClick={onOpen} aria-label={`打开 ${instance.name} 详情`}>
|
<article className="server-card" aria-label={`${instance.name} 服务器卡片`}>
|
||||||
<div className="server-card-head">
|
<div className="server-card-head">
|
||||||
<span>
|
<span>
|
||||||
<strong>{instance.name}</strong>
|
<strong>{instance.name}</strong>
|
||||||
@@ -289,10 +363,72 @@ function ServerCard({ card, metricsPending, onOpen }: ServerCardProps) {
|
|||||||
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
|
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
|
||||||
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
|
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="action-strip" style={{ justifyContent: "space-between" }}>
|
||||||
|
<button type="button" className="icon-command" onClick={onOpen}>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
<span>详情</span>
|
||||||
</button>
|
</button>
|
||||||
|
<details className="runtime-action-menu">
|
||||||
|
<summary className="icon-command">运行操作</summary>
|
||||||
|
<div className="action-list">
|
||||||
|
{serverQuickActions.map((action) => (
|
||||||
|
<button key={action} type="button" className="theme-upload" onClick={() => onQuickAction(action)}>
|
||||||
|
<Candy size={13} />
|
||||||
|
<span>{quickRuntimeActionLabel(action)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serverQuickActions: ServerQuickRuntimeAction[] = [
|
||||||
|
"generate-run",
|
||||||
|
"download-run",
|
||||||
|
"push-run-update",
|
||||||
|
"generate-client-manager",
|
||||||
|
"dependencies-check",
|
||||||
|
"dependencies-install",
|
||||||
|
"live-logs",
|
||||||
|
"historical-logs"
|
||||||
|
];
|
||||||
|
|
||||||
|
function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||||||
|
switch (action) {
|
||||||
|
case "generate-run":
|
||||||
|
return "生成 run";
|
||||||
|
case "download-run":
|
||||||
|
return "下载 run";
|
||||||
|
case "push-run-update":
|
||||||
|
return "推送更新";
|
||||||
|
case "generate-client-manager":
|
||||||
|
return "生成客户端";
|
||||||
|
case "dependencies-check":
|
||||||
|
return "依赖检查";
|
||||||
|
case "dependencies-install":
|
||||||
|
return "依赖安装";
|
||||||
|
case "live-logs":
|
||||||
|
return "实时日志";
|
||||||
|
case "historical-logs":
|
||||||
|
return "历史日志";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickRuntimeDefaultsForPlugin(pluginId: string) {
|
||||||
|
const isScum = pluginId.toLowerCase().includes("scum");
|
||||||
|
return {
|
||||||
|
runOs: isScum ? "windows" : "linux",
|
||||||
|
clientOs: "windows",
|
||||||
|
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
|
||||||
|
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
|
||||||
|
probeKey: isScum ? "steamcmd" : "java-21",
|
||||||
|
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
||||||
|
logSourceKey: isScum ? "server-log" : "latest-log"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatStat(value: number | undefined, pending: boolean, format: (value: number) => string): string {
|
function formatStat(value: number | undefined, pending: boolean, format: (value: number) => string): string {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
return format(value);
|
return format(value);
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { UsersPage } from "./UsersPage";
|
||||||
|
import type { UserResponse } from "../api/types";
|
||||||
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
|
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
|
||||||
|
import type { OperationTracker } from "../stores/operations";
|
||||||
|
|
||||||
|
const adminUser: CurrentUserView = {
|
||||||
|
id: "user-admin",
|
||||||
|
displayName: "Operator",
|
||||||
|
status: "active",
|
||||||
|
roles: ["platformAdmin"],
|
||||||
|
capabilities: capabilitiesForRoles(["platformAdmin"]),
|
||||||
|
profile: {},
|
||||||
|
source: "local"
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverUser: CurrentUserView = {
|
||||||
|
...adminUser,
|
||||||
|
id: "user-server",
|
||||||
|
roles: ["serverAdmin"],
|
||||||
|
capabilities: capabilitiesForRoles(["serverAdmin"])
|
||||||
|
};
|
||||||
|
|
||||||
|
const managedUser: UserResponse = {
|
||||||
|
id: "user-reviewer",
|
||||||
|
displayName: "Plugin Reviewer",
|
||||||
|
email: "reviewer@example.test",
|
||||||
|
status: "pending",
|
||||||
|
roles: ["server-admin"],
|
||||||
|
profile: { phone: "13800000000", qq: "10001", contactNote: "needs approval" },
|
||||||
|
createdAt: "2026-07-03T00:00:00Z",
|
||||||
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
const noopOperations: OperationTracker = {
|
||||||
|
operations: [],
|
||||||
|
begin: () => "op-test",
|
||||||
|
update: () => undefined,
|
||||||
|
succeed: () => undefined,
|
||||||
|
fail: () => undefined,
|
||||||
|
isPending: () => false
|
||||||
|
};
|
||||||
|
|
||||||
|
function pageProps(session = adminUser): PageComponentProps {
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
params: {},
|
||||||
|
operations: noopOperations,
|
||||||
|
onNavigate: () => undefined,
|
||||||
|
onLogout: async () => undefined,
|
||||||
|
onProfileSave: async () => session,
|
||||||
|
onThemePreferenceSave: async () => ({ userId: session.id, paletteId: "mecha-black", backgroundPresetId: "mecha-grid", persistence: "api", updatedAt: "2026-07-03T00:00:00Z" })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UsersPage", () => {
|
||||||
|
it("renders an empty API-backed state without local users", () => {
|
||||||
|
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [], loading: false, source: "api" }} />);
|
||||||
|
|
||||||
|
expect(html).toContain("暂无用户");
|
||||||
|
expect(html).toContain("page-summary-chip");
|
||||||
|
expect(html).not.toContain("metric-card");
|
||||||
|
expect(html).not.toContain("Plugin Reviewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders explicit local-development fixture state with a single status flow", () => {
|
||||||
|
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "local-development" }} />);
|
||||||
|
|
||||||
|
expect(html).toContain("Plugin Reviewer");
|
||||||
|
expect(html).toContain("本地开发样例 / 禁止假成功");
|
||||||
|
expect(html).toContain("编辑");
|
||||||
|
expect(html).toContain("状态");
|
||||||
|
expect(html).toContain("应用状态");
|
||||||
|
expect(html).toContain("停用");
|
||||||
|
expect(html).toContain("邀请用户");
|
||||||
|
expect(html).toContain("审核申请");
|
||||||
|
expect(html).toContain("绑定服务器范围");
|
||||||
|
expect(html).toContain("角色影响");
|
||||||
|
expect(html).toContain("needs approval");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render create or edit forms inline on the list page", () => {
|
||||||
|
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
|
||||||
|
|
||||||
|
expect(html).toContain("访问列表");
|
||||||
|
expect(html).toContain("邀请用户");
|
||||||
|
expect(html).not.toContain('role="dialog"');
|
||||||
|
expect(html).not.toContain('aria-label="编辑用户"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render account maintenance controls for non-admins", () => {
|
||||||
|
const html = renderToStaticMarkup(<UsersPage {...pageProps(serverUser)} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
|
||||||
|
|
||||||
|
expect(html).toContain("当前账号不能管理用户");
|
||||||
|
expect(html).not.toContain("邀请用户");
|
||||||
|
expect(html).not.toContain("停用");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { HeartHandshake, Sparkles, UserRoundCheck, UserRoundPlus } from "lucide-react";
|
import { HeartHandshake, Sparkles, UserPen, UserRoundCheck, UserRoundPlus } from "lucide-react";
|
||||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { UserCreateRequest, UserResponse, UserStatus } from "../api/types";
|
import type { UserCreateRequest, UserResponse, UserStatus } from "../api/types";
|
||||||
|
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
|
||||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import { PageFrame } from "../components/PageFrame";
|
import { PageFrame } from "../components/PageFrame";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import { userAccess } from "../contracts/shell";
|
import { userAccess } from "../contracts/shell";
|
||||||
|
import { type UserEditFormState, type UserListSource, type UserRemovalConfirmationState, userEditFormFromResponse } from "../contracts/users";
|
||||||
import { isPlatformAdmin } from "../contracts/workspace";
|
import { isPlatformAdmin } from "../contracts/workspace";
|
||||||
|
import { userCreateRequestFromDraft, userDeactivateRequest, userUpdateRequestFromEditForm } from "../schemas/users";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
|
|
||||||
const roleOptions = [
|
const roleOptions = [
|
||||||
@@ -37,12 +40,29 @@ const fallbackUsers: UserResponse[] = userAccess.map((user, index) => ({
|
|||||||
updatedAt: "2026-07-03T00:00:00Z"
|
updatedAt: "2026-07-03T00:00:00Z"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function UsersPage({ session, operations }: PageComponentProps) {
|
interface UsersPageInitialState {
|
||||||
const [users, setUsers] = useState<UserResponse[]>(fallbackUsers);
|
users?: UserResponse[];
|
||||||
const [loading, setLoading] = useState(true);
|
loading?: boolean;
|
||||||
const [source, setSource] = useState<"api" | "local">("local");
|
source?: UserListSource;
|
||||||
const [loadError, setLoadError] = useState("");
|
loadError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersPageProps extends PageComponentProps {
|
||||||
|
initialState?: UsersPageInitialState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPage({ session, operations, initialState }: UsersPageProps) {
|
||||||
|
const [users, setUsers] = useState<UserResponse[]>(initialState?.users ?? []);
|
||||||
|
const [loading, setLoading] = useState(initialState?.loading ?? true);
|
||||||
|
const [source, setSource] = useState<UserListSource>(initialState?.source ?? "api");
|
||||||
|
const [loadError, setLoadError] = useState(initialState?.loadError ?? "");
|
||||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string }>();
|
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string }>();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||||
|
const [editDraft, setEditDraft] = useState<UserEditFormState | null>(null);
|
||||||
|
const [confirmRemoval, setConfirmRemoval] = useState<UserRemovalConfirmationState | null>(null);
|
||||||
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
|
const [statusDrafts, setStatusDrafts] = useState<Record<string, UserStatus>>({});
|
||||||
const [draft, setDraft] = useState<UserCreateRequest>({
|
const [draft, setDraft] = useState<UserCreateRequest>({
|
||||||
displayName: "",
|
displayName: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -53,6 +73,15 @@ export function UsersPage({ session, operations }: PageComponentProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
if (initialState?.users) {
|
||||||
|
setUsers(initialState.users);
|
||||||
|
setLoading(initialState.loading ?? false);
|
||||||
|
setSource(initialState.source ?? "api");
|
||||||
|
setLoadError(initialState.loadError ?? "");
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
void platformApiClient
|
void platformApiClient
|
||||||
.listUsers()
|
.listUsers()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -64,9 +93,9 @@ export function UsersPage({ session, operations }: PageComponentProps) {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setUsers(fallbackUsers);
|
setUsers(import.meta.env.DEV ? fallbackUsers : []);
|
||||||
setSource("local");
|
setSource(import.meta.env.DEV ? "local-development" : "api");
|
||||||
setLoadError("账号 API 加载失败,当前显示本地样例;创建与状态更新仍会尝试平台 API。");
|
setLoadError(import.meta.env.DEV ? "账号 API 加载失败,当前显示本地开发样例;状态变更会被平台 API 拒绝或写入。" : "账号 API 加载失败,未显示本地样例数据。");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -77,7 +106,11 @@ export function UsersPage({ session, operations }: PageComponentProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [initialState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStatusDrafts(Object.fromEntries(users.map((user) => [user.id, user.status])));
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
const counts = useMemo(
|
const counts = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -87,34 +120,96 @@ export function UsersPage({ session, operations }: PageComponentProps) {
|
|||||||
}),
|
}),
|
||||||
[users]
|
[users]
|
||||||
);
|
);
|
||||||
|
const persistenceDisabled = source === "local-development";
|
||||||
|
|
||||||
async function createUser(event: FormEvent<HTMLFormElement>) {
|
async function createUser(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const request: UserCreateRequest = {
|
const request = userCreateRequestFromDraft(draft);
|
||||||
...draft,
|
const operationId = operations.begin({ intent: "邀请用户", targetKind: "platform", targetId: "users", requester: session.displayName });
|
||||||
displayName: draft.displayName.trim(),
|
setResult({ status: "pending", label: `正在发送邀请 ${operationId}` });
|
||||||
email: draft.email?.trim(),
|
|
||||||
roles: draft.roles.length ? draft.roles : ["server-admin"],
|
|
||||||
profile: {
|
|
||||||
phone: draft.profile?.phone?.trim(),
|
|
||||||
qq: draft.profile?.qq?.trim(),
|
|
||||||
contactNote: draft.profile?.contactNote?.trim()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const operationId = operations.begin({ intent: "创建用户", targetKind: "platform", targetId: "users", requester: session.displayName });
|
|
||||||
setResult({ status: "pending", label: `正在创建用户 ${operationId}` });
|
|
||||||
try {
|
try {
|
||||||
const created = await platformApiClient.createUser(request);
|
const created = await platformApiClient.createUser(request);
|
||||||
setUsers((current) => [created, ...current.filter((user) => user.id !== created.id)]);
|
setUsers((current) => [created, ...current.filter((user) => user.id !== created.id)]);
|
||||||
operations.succeed(operationId, `用户已创建:${created.id}`);
|
operations.succeed(operationId, `用户邀请已创建:${created.id}`);
|
||||||
setSource("api");
|
setSource("api");
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
setResult({ status: "succeeded", label: `已创建 ${created.displayName}` });
|
setResult({ status: "succeeded", label: `已邀请 ${created.displayName}` });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
operations.fail(operationId, error instanceof Error ? error.message : "用户创建失败");
|
operations.fail(operationId, error instanceof Error ? error.message : "用户创建失败");
|
||||||
setResult({ status: "failed", label: "用户创建失败,未写入数据库" });
|
setResult({ status: "failed", label: "用户创建失败,未写入数据库" });
|
||||||
}
|
}
|
||||||
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
|
}
|
||||||
|
|
||||||
|
function startEdit(user: UserResponse) {
|
||||||
|
setEditingUserId(user.id);
|
||||||
|
setEditDraft(userEditFormFromResponse(user));
|
||||||
|
setResult(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEdit() {
|
||||||
|
setEditingUserId(null);
|
||||||
|
setEditDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEdit<K extends keyof UserEditFormState>(key: K, value: UserEditFormState[K]) {
|
||||||
|
setEditDraft((current) => (current ? { ...current, [key]: value } : current));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEditRole(role: string) {
|
||||||
|
setEditDraft((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const roles = current.roles.includes(role) ? current.roles.filter((item) => item !== role) : [...current.roles, role];
|
||||||
|
return { ...current, roles };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserEdit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingUserId || !editDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const operationId = operations.begin({ intent: "编辑用户", targetKind: "platform", targetId: editingUserId, requester: session.displayName });
|
||||||
|
setResult({ status: "pending", label: "正在保存用户" });
|
||||||
|
try {
|
||||||
|
const updated = await platformApiClient.updateUser(editingUserId, userUpdateRequestFromEditForm(editDraft));
|
||||||
|
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
||||||
|
setEditingUserId(null);
|
||||||
|
setEditDraft(null);
|
||||||
|
setSource("api");
|
||||||
|
setLoadError("");
|
||||||
|
operations.succeed(operationId, `用户已更新:${updated.id}`);
|
||||||
|
setResult({ status: "succeeded", label: `已更新 ${updated.displayName}` });
|
||||||
|
} catch (error) {
|
||||||
|
operations.fail(operationId, error instanceof Error ? error.message : "用户更新失败");
|
||||||
|
setResult({ status: "failed", label: error instanceof Error ? error.message : "用户更新失败,未写入数据库" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivateUser() {
|
||||||
|
if (!confirmRemoval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmBusy(true);
|
||||||
|
const user = confirmRemoval.user;
|
||||||
|
const operationId = operations.begin({ intent: "停用用户", targetKind: "platform", targetId: user.id, requester: session.displayName });
|
||||||
|
try {
|
||||||
|
const updated = await platformApiClient.updateUser(user.id, userDeactivateRequest());
|
||||||
|
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
||||||
|
operations.succeed(operationId, `用户已停用:${updated.id}`);
|
||||||
|
setSource("api");
|
||||||
|
setLoadError("");
|
||||||
|
setResult({ status: "succeeded", label: `${updated.displayName} 已停用` });
|
||||||
|
setConfirmRemoval(null);
|
||||||
|
} catch (error) {
|
||||||
|
operations.fail(operationId, error instanceof Error ? error.message : "用户停用失败");
|
||||||
|
setResult({ status: "failed", label: error instanceof Error ? error.message : "停用失败,平台拒绝变更" });
|
||||||
|
} finally {
|
||||||
|
setConfirmBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setUserStatus(user: UserResponse, status: UserStatus) {
|
async function setUserStatus(user: UserResponse, status: UserStatus) {
|
||||||
@@ -140,6 +235,18 @@ export function UsersPage({ session, operations }: PageComponentProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyUserStatus(user: UserResponse) {
|
||||||
|
const nextStatus = statusDrafts[user.id] ?? user.status;
|
||||||
|
if (nextStatus === user.status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextStatus === "disabled") {
|
||||||
|
setConfirmRemoval({ action: "deactivate", user });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void setUserStatus(user, nextStatus);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isPlatformAdmin(session)) {
|
if (!isPlatformAdmin(session)) {
|
||||||
return (
|
return (
|
||||||
<div className="console-page">
|
<div className="console-page">
|
||||||
@@ -160,7 +267,7 @@ export function UsersPage({ session, operations }: PageComponentProps) {
|
|||||||
<PageFrame
|
<PageFrame
|
||||||
kicker="身份"
|
kicker="身份"
|
||||||
title="用户管理"
|
title="用户管理"
|
||||||
status={source === "api" ? "账号 API 已连接" : "本地回退"}
|
status={source === "api" ? "账号 API 已连接" : "本地开发数据"}
|
||||||
metrics={[
|
metrics={[
|
||||||
{ label: "用户", value: `${users.length}`, tone: "success" },
|
{ label: "用户", value: `${users.length}`, tone: "success" },
|
||||||
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
|
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
|
||||||
@@ -173,10 +280,75 @@ export function UsersPage({ session, operations }: PageComponentProps) {
|
|||||||
|
|
||||||
<section className="console-panel">
|
<section className="console-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>创建用户</h2>
|
<h2>访问列表</h2>
|
||||||
{loading ? <ResultBadge status="pending" label="加载用户…" /> : result && <ResultBadge status={result.status} label={result.label} />}
|
{loading ? <ResultBadge status="pending" label="加载用户…" /> : result && <ResultBadge status={result.status} label={result.label} />}
|
||||||
|
<span className="page-status">{source === "api" ? "平台数据" : "本地开发样例 / 禁止假成功"}</span>
|
||||||
|
<button type="button" className="primary-command" disabled={persistenceDisabled} onClick={() => setCreateOpen(true)}>
|
||||||
|
<UserRoundPlus size={14} />
|
||||||
|
<span>{persistenceDisabled ? "等待 API" : "邀请用户"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AccessFlowGuide />
|
||||||
|
<RoleImpactGuide />
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。邀请用户后会在这里显示 API 连接结果。" actionLabel="邀请用户" onAction={() => setCreateOpen(true)} />
|
||||||
|
) : (
|
||||||
|
<div className="resource-list user-management-list">
|
||||||
|
{users.map((user) => (
|
||||||
|
<article key={user.id} className="resource-list-item user-management-item">
|
||||||
|
<div>
|
||||||
|
<strong>{user.displayName}</strong>
|
||||||
|
<span className="provider-id">{user.email ?? user.id}</span>
|
||||||
|
</div>
|
||||||
|
<span className={cx("status-pill", statusClass(user.status))}>
|
||||||
|
<UserRoundCheck size={13} />
|
||||||
|
{statusLabel(user.status)}
|
||||||
|
</span>
|
||||||
|
<span>{user.roles.map(roleLabel).join(" / ")}</span>
|
||||||
|
<span>{profileSummary(user)}</span>
|
||||||
|
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
|
||||||
|
<button type="button" className="theme-upload" disabled={persistenceDisabled} aria-label={`编辑 ${user.displayName}`} onClick={() => startEdit(user)}>
|
||||||
|
<UserPen size={13} />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<label className="status-update-control">
|
||||||
|
<span>状态</span>
|
||||||
|
<select
|
||||||
|
value={statusDrafts[user.id] ?? user.status}
|
||||||
|
disabled={persistenceDisabled}
|
||||||
|
aria-label={`选择 ${user.displayName} 状态`}
|
||||||
|
onChange={(event) => setStatusDrafts((current) => ({ ...current, [user.id]: event.target.value as UserStatus }))}
|
||||||
|
>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="theme-upload"
|
||||||
|
disabled={persistenceDisabled || (statusDrafts[user.id] ?? user.status) === user.status}
|
||||||
|
aria-label={`应用 ${user.displayName} 状态变更`}
|
||||||
|
onClick={() => applyUserStatus(user)}
|
||||||
|
>
|
||||||
|
<UserRoundCheck size={13} />
|
||||||
|
应用状态
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ManagementDialog open={createOpen} title="邀请用户" wide onClose={() => setCreateOpen(false)}>
|
||||||
|
<form className="management-form dialog-form" onSubmit={createUser}>
|
||||||
|
<div className="form-guidance management-form-wide">
|
||||||
|
<strong>邀请与审核流程</strong>
|
||||||
|
<span>建议先以“待审核”创建账号;平台管理员确认身份后再启用。服务器范围在服务器详情的“管理成员”里绑定,避免给错全局权限。</span>
|
||||||
</div>
|
</div>
|
||||||
<form className="management-form" onSubmit={createUser}>
|
|
||||||
<label>
|
<label>
|
||||||
<span>显示名称</span>
|
<span>显示名称</span>
|
||||||
<input value={draft.displayName} required onChange={(event) => setDraft((current) => ({ ...current, displayName: event.target.value }))} />
|
<input value={draft.displayName} required onChange={(event) => setDraft((current) => ({ ...current, displayName: event.target.value }))} />
|
||||||
@@ -226,53 +398,119 @@ export function UsersPage({ session, operations }: PageComponentProps) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="profile-save-button">
|
<RoleImpactGuide />
|
||||||
|
<div className="confirm-actions">
|
||||||
|
<button type="button" onClick={() => setCreateOpen(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="confirm-primary" disabled={persistenceDisabled}>
|
||||||
<UserRoundPlus size={14} />
|
<UserRoundPlus size={14} />
|
||||||
<span>创建用户</span>
|
<span>{persistenceDisabled ? "等待 API" : "发送邀请"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</ManagementDialog>
|
||||||
|
|
||||||
<section className="console-panel">
|
<ManagementDialog open={editDraft !== null} title="编辑用户" wide onClose={closeEdit}>
|
||||||
<div className="panel-header">
|
{editDraft && (
|
||||||
<h2>访问列表</h2>
|
<form className="management-form dialog-form" onSubmit={(event) => void saveUserEdit(event)}>
|
||||||
<span className="page-status">{source === "api" ? "平台数据" : "本地样例 / 待同步"}</span>
|
<label>
|
||||||
</div>
|
<span>显示名称</span>
|
||||||
{users.length === 0 ? (
|
<input value={editDraft.displayName} required onChange={(event) => updateEdit("displayName", event.target.value)} />
|
||||||
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。创建用户后会在这里显示 API 连接结果。" />
|
</label>
|
||||||
) : (
|
<label>
|
||||||
<div className="resource-list user-management-list">
|
<span>邮箱</span>
|
||||||
{users.map((user) => (
|
<input value={editDraft.email} type="email" onChange={(event) => updateEdit("email", event.target.value)} />
|
||||||
<article key={user.id} className="resource-list-item user-management-item">
|
</label>
|
||||||
<div>
|
<label>
|
||||||
<strong>{user.displayName}</strong>
|
<span>手机号</span>
|
||||||
<span className="provider-id">{user.email ?? user.id}</span>
|
<input value={editDraft.phone} inputMode="tel" onChange={(event) => updateEdit("phone", event.target.value)} />
|
||||||
</div>
|
</label>
|
||||||
<span className={cx("status-pill", statusClass(user.status))}>
|
<label>
|
||||||
<UserRoundCheck size={13} />
|
<span>QQ</span>
|
||||||
{statusLabel(user.status)}
|
<input value={editDraft.qq} inputMode="numeric" onChange={(event) => updateEdit("qq", event.target.value)} />
|
||||||
</span>
|
</label>
|
||||||
<span>{user.roles.map(roleLabel).join(" / ")}</span>
|
<label>
|
||||||
<span>{profileSummary(user)}</span>
|
<span>备注</span>
|
||||||
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
|
<input value={editDraft.contactNote} onChange={(event) => updateEdit("contactNote", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>状态</span>
|
||||||
|
<select value={editDraft.status} onChange={(event) => updateEdit("status", event.target.value as UserStatus)}>
|
||||||
{statusOptions.map((option) => (
|
{statusOptions.map((option) => (
|
||||||
<button
|
<option key={option.value} value={option.value}>
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className="theme-upload"
|
|
||||||
disabled={user.status === option.value}
|
|
||||||
aria-label={`将 ${user.displayName} 设为${option.label}`}
|
|
||||||
onClick={() => void setUserStatus(user, option.value)}
|
|
||||||
>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="role-selector" aria-label="编辑角色">
|
||||||
|
{roleOptions.map((role) => (
|
||||||
|
<button key={role.value} type="button" className={cx("role-chip", editDraft.roles.includes(role.value) && "role-chip-active")} onClick={() => toggleEditRole(role.value)}>
|
||||||
|
<Sparkles size={13} />
|
||||||
|
<span>{role.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<RoleImpactGuide />
|
||||||
))}
|
<div className="confirm-actions">
|
||||||
|
<button type="button" onClick={closeEdit}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="confirm-primary">
|
||||||
|
<UserPen size={14} />
|
||||||
|
<span>保存用户</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
</section>
|
</ManagementDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmRemoval !== null}
|
||||||
|
title="停用用户"
|
||||||
|
description={`确认停用 ${confirmRemoval?.user.displayName ?? ""}?该账号将无法登录,已有审计记录会保留。`}
|
||||||
|
confirmLabel="确认停用"
|
||||||
|
danger
|
||||||
|
busy={confirmBusy}
|
||||||
|
onCancel={() => {
|
||||||
|
if (confirmRemoval) {
|
||||||
|
setStatusDrafts((current) => ({ ...current, [confirmRemoval.user.id]: confirmRemoval.user.status }));
|
||||||
|
}
|
||||||
|
setConfirmRemoval(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => void deactivateUser()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccessFlowGuide() {
|
||||||
|
return (
|
||||||
|
<div className="workflow-hint-grid" aria-label="用户管理流程提示">
|
||||||
|
<div className="workflow-hint-card">
|
||||||
|
<strong>邀请用户</strong>
|
||||||
|
<span>默认待审核,先收集联系方式和备注。</span>
|
||||||
|
</div>
|
||||||
|
<div className="workflow-hint-card">
|
||||||
|
<strong>审核申请</strong>
|
||||||
|
<span>确认身份后启用;离职或拒绝统一走停用确认。</span>
|
||||||
|
</div>
|
||||||
|
<div className="workflow-hint-card">
|
||||||
|
<strong>绑定服务器范围</strong>
|
||||||
|
<span>服主/管理员的具体服务器范围在服务器详情中维护。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleImpactGuide() {
|
||||||
|
return (
|
||||||
|
<div className="role-impact-list management-form-wide" aria-label="角色影响说明">
|
||||||
|
<strong>角色影响</strong>
|
||||||
|
<span>平台管理员:可管理用户、插件、AI 提供商和维护页。</span>
|
||||||
|
<span>服主:管理自己名下服务器,并可邀请或移除服务器管理员。</span>
|
||||||
|
<span>服务器管理员:只进入服务器工作区,适合日常值班。</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AiProviderRequest, AiProviderUpdateRequest } from "../api/types";
|
import type { AiProviderRequest, AiProviderStatusRequest, AiProviderUpdateRequest } from "../api/types";
|
||||||
import type { AiProviderFormState } from "../contracts/aiProviders";
|
import type { AiProviderFormState } from "../contracts/aiProviders";
|
||||||
|
|
||||||
export function aiProviderCreateRequestFromForm(form: AiProviderFormState): AiProviderRequest {
|
export function aiProviderCreateRequestFromForm(form: AiProviderFormState): AiProviderRequest {
|
||||||
@@ -26,3 +26,7 @@ export function aiProviderUpdateRequestFromForm(form: AiProviderFormState): AiPr
|
|||||||
redactionPolicy: form.redactionPolicy.trim() || "default"
|
redactionPolicy: form.redactionPolicy.trim() || "default"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function aiProviderRetireRequest(): AiProviderStatusRequest {
|
||||||
|
return { status: "disabled" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { ServerLifecycleCommandRequest, ServerLifecycleCreateRequest, ServerInstanceResponse } from "../api/types";
|
import type {
|
||||||
import type { ServerCreateFormState } from "../contracts/serverManagement";
|
ClientManagerBuildRequest,
|
||||||
|
DependencyJobRequest,
|
||||||
|
LogBackfillRequest,
|
||||||
|
RunDistributionGenerateRequest,
|
||||||
|
RunUpdateRequest,
|
||||||
|
ServerInstanceUpdateRequest,
|
||||||
|
ServerLifecycleCommandRequest,
|
||||||
|
ServerLifecycleCreateRequest,
|
||||||
|
ServerInstanceResponse
|
||||||
|
} from "../api/types";
|
||||||
|
import type { ServerCreateFormState, ServerMetadataFormState, ServerRemovalConfirmationState } from "../contracts/serverManagement";
|
||||||
|
|
||||||
export function serverCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
|
export function serverCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
|
||||||
const id = form.id.trim();
|
const id = form.id.trim();
|
||||||
@@ -19,6 +29,75 @@ export function serverLifecycleCommandRequest(instance: ServerInstanceResponse,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serverMetadataUpdateRequestFromForm(form: ServerMetadataFormState): ServerInstanceUpdateRequest {
|
||||||
|
return { name: form.name.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverArchiveConfirmation(instance: ServerInstanceResponse): ServerRemovalConfirmationState {
|
||||||
|
return {
|
||||||
|
action: "archive",
|
||||||
|
serverInstanceId: instance.id,
|
||||||
|
name: instance.name,
|
||||||
|
state: instance.state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runDistributionGenerateRequest(serverInstanceId: string, targetOs: string, targetArch: string, sequence = Date.now()): RunDistributionGenerateRequest {
|
||||||
|
return {
|
||||||
|
targetOs,
|
||||||
|
targetArch,
|
||||||
|
idempotencyKey: runtimeIdempotencyKey("run.generate", serverInstanceId, sequence)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runUpdateRequest(serverInstanceId: string, artifactId: string, checksum?: string, sequence = Date.now()): RunUpdateRequest {
|
||||||
|
return {
|
||||||
|
artifactId,
|
||||||
|
checksum,
|
||||||
|
idempotencyKey: runtimeIdempotencyKey("run.update", serverInstanceId, sequence)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientManagerBuildRequest(input: {
|
||||||
|
serverInstanceId: string;
|
||||||
|
profileKey: string;
|
||||||
|
targetOs: string;
|
||||||
|
targetArch: string;
|
||||||
|
repositoryUrl: string;
|
||||||
|
sourceRevision?: string;
|
||||||
|
sequence?: number;
|
||||||
|
}): ClientManagerBuildRequest {
|
||||||
|
return {
|
||||||
|
profileKey: input.profileKey.trim(),
|
||||||
|
targetOs: input.targetOs,
|
||||||
|
targetArch: input.targetArch,
|
||||||
|
repositoryUrl: input.repositoryUrl.trim(),
|
||||||
|
sourceRevision: input.sourceRevision?.trim() || undefined,
|
||||||
|
idempotencyKey: runtimeIdempotencyKey("client-manager.generate", input.serverInstanceId, input.sequence ?? Date.now())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", sequence = Date.now()): DependencyJobRequest {
|
||||||
|
return {
|
||||||
|
probeKey: probeKey.trim(),
|
||||||
|
installPlanKey: installPlanKey.trim() || undefined,
|
||||||
|
idempotencyKey: runtimeIdempotencyKey(installPlanKey ? "dependencies.install" : "dependencies.check", serverInstanceId, sequence)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logBackfillRequest(serverInstanceId: string, sourceKey: string, checkpointRef = "", limit = 200, sequence = Date.now()): LogBackfillRequest {
|
||||||
|
return {
|
||||||
|
sourceKey: sourceKey.trim(),
|
||||||
|
checkpointRef: checkpointRef.trim() || undefined,
|
||||||
|
limit,
|
||||||
|
idempotencyKey: runtimeIdempotencyKey("logs.backfill", serverInstanceId, sequence)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeIdempotencyKey(action: string, serverInstanceId: string, sequence: number): string {
|
||||||
|
return `web:${action}:${serverInstanceId}:${sequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop", serverInstanceId: string, sequence: number): string {
|
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop", serverInstanceId: string, sequence: number): string {
|
||||||
return `web:${action}:${serverInstanceId}:${sequence}`;
|
return `web:${action}:${serverInstanceId}:${sequence}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { UserCreateRequest, UserUpdateRequest } from "../api/types";
|
||||||
|
import type { UserEditFormState } from "../contracts/users";
|
||||||
|
|
||||||
|
export function userCreateRequestFromDraft(draft: UserCreateRequest): UserCreateRequest {
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
displayName: draft.displayName.trim(),
|
||||||
|
email: draft.email?.trim(),
|
||||||
|
roles: draft.roles.length ? draft.roles : ["server-admin"],
|
||||||
|
profile: {
|
||||||
|
phone: draft.profile?.phone?.trim(),
|
||||||
|
qq: draft.profile?.qq?.trim(),
|
||||||
|
contactNote: draft.profile?.contactNote?.trim()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userUpdateRequestFromEditForm(form: UserEditFormState): UserUpdateRequest {
|
||||||
|
return {
|
||||||
|
displayName: form.displayName.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
roles: form.roles,
|
||||||
|
status: form.status,
|
||||||
|
profile: {
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
qq: form.qq.trim(),
|
||||||
|
contactNote: form.contactNote.trim()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userDeactivateRequest(): UserUpdateRequest {
|
||||||
|
return { status: "disabled" };
|
||||||
|
}
|
||||||
+386
-9
@@ -1203,6 +1203,48 @@ button {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-hint-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-hint-card,
|
||||||
|
.form-guidance,
|
||||||
|
.role-impact-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
box-shadow: inset 0 1px 0 var(--crystal-rim);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-hint-card strong,
|
||||||
|
.form-guidance strong,
|
||||||
|
.role-impact-list strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-form-wide,
|
||||||
|
.role-impact-list {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-impact-list {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-form .role-impact-list {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.role-selector {
|
.role-selector {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1225,6 +1267,20 @@ button {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-update-control {
|
||||||
|
min-width: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-update-control select {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--surface-solid);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-settings-page {
|
.profile-settings-page {
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1408,7 @@ button {
|
|||||||
.page-frame {
|
.page-frame {
|
||||||
width: min(100%, 1180px);
|
width: min(100%, 1180px);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -1409,6 +1465,57 @@ button {
|
|||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-summary-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: -2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 52%, transparent);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(1.1);
|
||||||
|
backdrop-filter: blur(14px) saturate(1.1);
|
||||||
|
box-shadow: inset 0 1px 0 var(--crystal-rim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary-chip dt,
|
||||||
|
.page-summary-chip dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary-chip dt {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary-chip dd {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-tone-success {
|
||||||
|
border-left-color: var(--teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-tone-warning {
|
||||||
|
border-left-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-tone-neutral {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- metric cards ---- */
|
/* ---- metric cards ---- */
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
@@ -2598,10 +2705,6 @@ button {
|
|||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-provider-metrics {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-provider-toolbar {
|
.ai-provider-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2610,7 +2713,6 @@ button {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-provider-workspace,
|
|
||||||
.server-workspace {
|
.server-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
|
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
|
||||||
@@ -2618,7 +2720,6 @@ button {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-provider-workspace > *,
|
|
||||||
.server-workspace > * {
|
.server-workspace > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -2743,6 +2844,68 @@ button {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.provider-actions-cell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.human-row-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.human-row-actions button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 58px;
|
||||||
|
height: 32px;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.human-row-actions .row-action-button-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
background: var(--glass-wash), var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-action-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 4;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 160px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--frosted-surface), var(--surface-solid);
|
||||||
|
box-shadow: var(--jelly-inset), 0 16px 36px var(--glass-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-action-menu button {
|
||||||
|
min-height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-action-menu button:hover,
|
||||||
|
.inline-action-menu button:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.form-header {
|
.form-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2779,6 +2942,61 @@ button {
|
|||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.provider-setup-guide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-preset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-preset-option {
|
||||||
|
min-height: 74px;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: inset 0 1px 0 var(--crystal-rim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-preset-option strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-preset-option span {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-preset-option:hover,
|
||||||
|
.provider-preset-option:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-helper-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.provider-form label {
|
.provider-form label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -3204,6 +3422,60 @@ button {
|
|||||||
box-shadow: var(--panel-shadow);
|
box-shadow: var(--panel-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.management-dialog-panel {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
height: auto;
|
||||||
|
max-height: min(760px, calc(100dvh - 32px));
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--jelly-inset), inset 0 0 0 1px var(--diamond-line), 0 18px 48px var(--glass-shadow), 0 0 34px var(--moonbeam);
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-dialog-wide {
|
||||||
|
width: min(920px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-dialog-panel .provider-form,
|
||||||
|
.management-dialog-panel .management-form {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-dialog-panel .management-form {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-dialog-panel .dialog-form .role-selector,
|
||||||
|
.management-dialog-panel .dialog-form .confirm-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-dialog-panel .plugin-detail-panel,
|
||||||
|
.management-dialog-panel .plugin-group {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
clip-path: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-dialog-panel .plugin-group::before,
|
||||||
|
.management-dialog-panel .plugin-group::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-detail-actions {
|
.plugin-detail-actions {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -3327,6 +3599,104 @@ button {
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- maintenance triage ---- */
|
||||||
|
|
||||||
|
.maintenance-triage-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-card {
|
||||||
|
min-height: 120px;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--corner-sparkle), var(--jelly-highlight), var(--glass-wash), var(--surface);
|
||||||
|
background-size: 52px 52px, auto, auto, auto;
|
||||||
|
background-position: right 8px top 6px, center, center, center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: inset 0 1px 0 var(--crystal-rim), var(--panel-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-card svg {
|
||||||
|
color: var(--accent-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-card span {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-card strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-card small {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-card:hover,
|
||||||
|
.triage-card:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft), var(--panel-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-node-item {
|
||||||
|
grid-template-columns: minmax(170px, 1fr) auto minmax(160px, 0.8fr) minmax(220px, 1.2fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-node-item .node-detail,
|
||||||
|
.maintenance-node-item .maintenance-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail summary {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail[open] {
|
||||||
|
background: var(--glass-wash), var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail span {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-actions .theme-upload {
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- operation history ---- */
|
/* ---- operation history ---- */
|
||||||
|
|
||||||
.operation-list {
|
.operation-list {
|
||||||
@@ -3498,6 +3868,10 @@ button {
|
|||||||
.confirm-actions button {
|
.confirm-actions button {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: var(--surface-solid);
|
background: var(--surface-solid);
|
||||||
@@ -3533,6 +3907,10 @@ button {
|
|||||||
|
|
||||||
.management-form,
|
.management-form,
|
||||||
.user-management-item,
|
.user-management-item,
|
||||||
|
.workflow-hint-grid,
|
||||||
|
.provider-preset-grid,
|
||||||
|
.maintenance-triage-grid,
|
||||||
|
.maintenance-node-item,
|
||||||
.profile-settings-hero,
|
.profile-settings-hero,
|
||||||
.profile-settings-grid,
|
.profile-settings-grid,
|
||||||
.profile-settings-form-row,
|
.profile-settings-form-row,
|
||||||
@@ -3542,6 +3920,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-actions,
|
.user-actions,
|
||||||
|
.maintenance-actions,
|
||||||
.profile-settings-actions {
|
.profile-settings-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -3682,9 +4061,7 @@ button {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-provider-metrics,
|
|
||||||
.server-metrics,
|
.server-metrics,
|
||||||
.ai-provider-workspace,
|
|
||||||
.server-workspace,
|
.server-workspace,
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
+26
-2
@@ -10,6 +10,8 @@ A game management plugin defines how the platform creates and manages one type o
|
|||||||
- Create-server form schema.
|
- Create-server form schema.
|
||||||
- Lifecycle action definitions.
|
- Lifecycle action definitions.
|
||||||
- Required run capabilities.
|
- Required run capabilities.
|
||||||
|
- Optional remote access methods and remote run capabilities.
|
||||||
|
- Optional runtime profiles for discovery, lifecycle modes, dependency probes, install plans, log sources, transports, and client-manager builds.
|
||||||
- Optional plugin pages hosted by platform_web.
|
- Optional plugin pages hosted by platform_web.
|
||||||
- AI/file/log permissions declared for platform authorization.
|
- AI/file/log permissions declared for platform authorization.
|
||||||
|
|
||||||
@@ -25,7 +27,29 @@ Implementation should use dedicated directories for:
|
|||||||
|
|
||||||
Plugins must use the platform bridge and must not connect directly to run, log storage, artifact storage internals, or AI provider endpoints.
|
Plugins must use the platform bridge and must not connect directly to run, log storage, artifact storage internals, or AI provider endpoints.
|
||||||
|
|
||||||
Manifest validation is the plugin-side installability gate. The shared manifest schema defines identity, version, server type/display metadata, create form schema reference, lifecycle action references, required run capabilities, scoped permissions, optional pages, tags, and AI purposes. `scripts/validate-manifest.ts` also scans manifest and create-form content for unsafe raw host path, raw credential, direct run, and raw AI/provider key requests.
|
Manifest validation is the plugin-side installability gate. The shared manifest schema defines identity, version, server type/display metadata, create form schema reference, lifecycle action references, required run capabilities, scoped permissions, remote access declarations, runtime profiles, optional pages, tags, and AI purposes. `scripts/validate-manifest.ts` also scans manifest and create-form content for unsafe raw host path, raw credential, direct run, and raw AI/provider key requests.
|
||||||
|
|
||||||
|
Remote access declarations describe whether a plugin can use `ftp`, `rsync`, or `run`, and which `remote.*` run capabilities are enabled for that game. Runtime profiles describe how run discovers servers, checks dependencies, tails live logs, backfills historical logs, resolves transports, and builds optional client managers. Plugin pages use platform-mediated bridge actions; they never receive FTP passwords, rsync endpoints, database DSNs, RCON credentials, run/client keys, run sockets, or host paths.
|
||||||
|
|
||||||
|
Runtime profiles are declarative contracts, not executable scripts. A profile can declare:
|
||||||
|
|
||||||
|
- discovery probes for logical targets such as Java, Steam app, service, file, or toolchain checks.
|
||||||
|
- lifecycle modes such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`.
|
||||||
|
- dependency probes and typed install plans for supported OS targets.
|
||||||
|
- log sources for stdout/stderr, file tailing, FTP polling, SQL cursors, or plugin-specific client-manager logs.
|
||||||
|
- transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations.
|
||||||
|
- client-manager build profiles for games such as SCUM that need a separate companion executable.
|
||||||
|
|
||||||
|
Client-manager profiles declare repository URL, revision policy, supported target OS/architecture pairs, build system hints, config template keys, dependency hints, and produced artifact paths. Platform performs target validation, creates a build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. The run key and client-manager key are separate singleton keys in platform storage; resetting either key revokes packages from older generations and requires regenerating that component.
|
||||||
|
|
||||||
|
Plugin pages may request these operations only through bridge helpers:
|
||||||
|
|
||||||
|
- `createRunDistributionRequest`: generate/download/reset/update run packages.
|
||||||
|
- `createDependencyActionRequest`: check or install declared dependency probes/plans.
|
||||||
|
- `createLogBackfillRequest`: request historical log cursors for declared sources.
|
||||||
|
- `createClientManagerRequest`: generate/download/reset declared client-manager packages.
|
||||||
|
|
||||||
|
Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, and idempotency keys only. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets.
|
||||||
|
|
||||||
Validated manifests are registered through the platform registry API rather than by plugin code importing platform internals. Platform stores registry metadata only and repeats safety validation before a plugin becomes installable.
|
Validated manifests are registered through the platform registry API rather than by plugin code importing platform internals. Platform stores registry metadata only and repeats safety validation before a plugin becomes installable.
|
||||||
|
|
||||||
@@ -46,4 +70,4 @@ npm run test
|
|||||||
npm run validate:manifest
|
npm run validate:manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin` fixture, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, and platform-mediated lifecycle job dispatch. Marketplace package acquisition, remote plugin hosting policies, and external package distribution remain future OpenSpec work.
|
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, run distribution envelopes, typed dependency/log backfill requests, and SCUM-style client-manager build declarations. Marketplace package acquisition, private source credentials, public build-worker sandboxing, real FTP/rsync/database/RCON adapters beyond bounded envelopes, remote plugin hosting policies, and external package distribution remain future OpenSpec work.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"command": ["true"],
|
||||||
|
"env": {
|
||||||
|
"GAME_ID": "minecraft",
|
||||||
|
"SERVER_TEMPLATE": "minecraft-java"
|
||||||
|
},
|
||||||
|
"timeoutMs": 30000
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"command": ["true"],
|
||||||
|
"env": {
|
||||||
|
"GAME_ID": "minecraft",
|
||||||
|
"SERVER_ACTION": "restart"
|
||||||
|
},
|
||||||
|
"timeoutMs": 30000
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"command": ["true"],
|
||||||
|
"env": {
|
||||||
|
"GAME_ID": "minecraft",
|
||||||
|
"SERVER_ACTION": "start"
|
||||||
|
},
|
||||||
|
"timeoutMs": 30000
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"command": ["true"],
|
||||||
|
"env": {
|
||||||
|
"GAME_ID": "minecraft",
|
||||||
|
"SERVER_ACTION": "status"
|
||||||
|
},
|
||||||
|
"timeoutMs": 30000
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"command": ["true"],
|
||||||
|
"env": {
|
||||||
|
"GAME_ID": "minecraft",
|
||||||
|
"SERVER_ACTION": "stop"
|
||||||
|
},
|
||||||
|
"timeoutMs": 30000
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../manifests/game-plugin.manifest.schema.json",
|
||||||
|
"id": "game.minecraft",
|
||||||
|
"name": "Minecraft Server",
|
||||||
|
"description": "First-party Minecraft server management plugin for run-mediated files, logs, and RCON.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"kind": "game-plugin",
|
||||||
|
"tags": [
|
||||||
|
"minecraft",
|
||||||
|
"sandbox",
|
||||||
|
"rcon",
|
||||||
|
"run"
|
||||||
|
],
|
||||||
|
"server": {
|
||||||
|
"type": "minecraft",
|
||||||
|
"displayName": "Minecraft Java Server",
|
||||||
|
"supportedOS": [
|
||||||
|
"windows",
|
||||||
|
"linux",
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"createFormSchema": "schemas/create-form.schema.json"
|
||||||
|
},
|
||||||
|
"capabilities": [
|
||||||
|
"process.install",
|
||||||
|
"process.start",
|
||||||
|
"process.stop",
|
||||||
|
"process.restart",
|
||||||
|
"process.status",
|
||||||
|
"files.list",
|
||||||
|
"files.read",
|
||||||
|
"files.write",
|
||||||
|
"files.patch",
|
||||||
|
"logs.read",
|
||||||
|
"remote.run.files.read",
|
||||||
|
"remote.run.files.write",
|
||||||
|
"remote.run.process.start",
|
||||||
|
"remote.run.process.stop",
|
||||||
|
"remote.run.logs.transfer",
|
||||||
|
"remote.run.rcon.command",
|
||||||
|
"artifacts.read",
|
||||||
|
"artifacts.write",
|
||||||
|
"ai.invoke"
|
||||||
|
],
|
||||||
|
"remoteAccess": {
|
||||||
|
"methods": [
|
||||||
|
"run"
|
||||||
|
],
|
||||||
|
"runCapabilities": [
|
||||||
|
"remote.run.files.read",
|
||||||
|
"remote.run.files.write",
|
||||||
|
"remote.run.process.start",
|
||||||
|
"remote.run.process.stop",
|
||||||
|
"remote.run.logs.transfer",
|
||||||
|
"remote.run.rcon.command"
|
||||||
|
],
|
||||||
|
"rcon": true,
|
||||||
|
"logTransfer": true
|
||||||
|
},
|
||||||
|
"bridge": {
|
||||||
|
"actions": [
|
||||||
|
"server.instances.read",
|
||||||
|
"jobs.dispatch",
|
||||||
|
"logs.query",
|
||||||
|
"artifacts.open",
|
||||||
|
"files.request",
|
||||||
|
"remote.access.request",
|
||||||
|
"ai.invoke",
|
||||||
|
"run.distribution.request",
|
||||||
|
"dependencies.request",
|
||||||
|
"logs.backfill.request"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"server.create",
|
||||||
|
"server.read",
|
||||||
|
"server.lifecycle",
|
||||||
|
"server.files.read",
|
||||||
|
"server.files.write",
|
||||||
|
"server.logs.read",
|
||||||
|
"server.artifacts.read",
|
||||||
|
"server.artifacts.write",
|
||||||
|
"server.remote.access",
|
||||||
|
"ai.invoke",
|
||||||
|
"server.run.distribution",
|
||||||
|
"server.dependencies.manage"
|
||||||
|
],
|
||||||
|
"actions": {
|
||||||
|
"install": "actions/install.json",
|
||||||
|
"start": "actions/start.json",
|
||||||
|
"stop": "actions/stop.json",
|
||||||
|
"restart": "actions/restart.json",
|
||||||
|
"status": "actions/status.json"
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"key": "overview",
|
||||||
|
"title": "MC 概览",
|
||||||
|
"path": "/overview",
|
||||||
|
"permissions": [
|
||||||
|
"server.read",
|
||||||
|
"server.lifecycle"
|
||||||
|
],
|
||||||
|
"bridgeActions": [
|
||||||
|
"server.instances.read",
|
||||||
|
"jobs.dispatch"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "remote",
|
||||||
|
"title": "MC 远程",
|
||||||
|
"path": "/remote",
|
||||||
|
"permissions": [
|
||||||
|
"server.remote.access",
|
||||||
|
"server.files.read",
|
||||||
|
"server.files.write",
|
||||||
|
"server.logs.read"
|
||||||
|
],
|
||||||
|
"bridgeActions": [
|
||||||
|
"remote.access.request",
|
||||||
|
"files.request",
|
||||||
|
"logs.query"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rcon",
|
||||||
|
"title": "MC RCON",
|
||||||
|
"path": "/rcon",
|
||||||
|
"permissions": [
|
||||||
|
"server.remote.access"
|
||||||
|
],
|
||||||
|
"bridgeActions": [
|
||||||
|
"remote.access.request"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ai": {
|
||||||
|
"purposes": [
|
||||||
|
"config.suggest",
|
||||||
|
"logs.diagnose"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runtimeProfiles": {
|
||||||
|
"discovery": [
|
||||||
|
{
|
||||||
|
"key": "java-runtime",
|
||||||
|
"kind": "command.version",
|
||||||
|
"targetKey": "java",
|
||||||
|
"required": true,
|
||||||
|
"expected": "21"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "server-jar",
|
||||||
|
"kind": "file.exists",
|
||||||
|
"targetKey": "server.jar",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lifecycleProfiles": [
|
||||||
|
{
|
||||||
|
"key": "run-local",
|
||||||
|
"mode": "local-process",
|
||||||
|
"capabilities": [
|
||||||
|
"process.install",
|
||||||
|
"process.start",
|
||||||
|
"process.stop",
|
||||||
|
"process.restart",
|
||||||
|
"process.status",
|
||||||
|
"remote.run.process.start",
|
||||||
|
"remote.run.process.stop"
|
||||||
|
],
|
||||||
|
"actionRefs": {
|
||||||
|
"install": "actions/install.json",
|
||||||
|
"start": "actions/start.json",
|
||||||
|
"stop": "actions/stop.json",
|
||||||
|
"restart": "actions/restart.json",
|
||||||
|
"status": "actions/status.json"
|
||||||
|
},
|
||||||
|
"transportKeys": [
|
||||||
|
"server-files",
|
||||||
|
"rcon"
|
||||||
|
],
|
||||||
|
"platforms": [
|
||||||
|
"windows",
|
||||||
|
"linux",
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencyProbes": [
|
||||||
|
{
|
||||||
|
"key": "java-21",
|
||||||
|
"kind": "java.version",
|
||||||
|
"targetKey": "java",
|
||||||
|
"required": true,
|
||||||
|
"minimumVersion": "21",
|
||||||
|
"platforms": [
|
||||||
|
"windows",
|
||||||
|
"linux",
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"installPlans": [
|
||||||
|
{
|
||||||
|
"key": "install-java-linux",
|
||||||
|
"title": "Install Java runtime",
|
||||||
|
"platforms": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "package",
|
||||||
|
"targetKey": "java",
|
||||||
|
"packageManager": "apt",
|
||||||
|
"packageName": "openjdk-21-jre"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"logSources": [
|
||||||
|
{
|
||||||
|
"key": "console",
|
||||||
|
"kind": "process.stdout",
|
||||||
|
"streamKey": "console",
|
||||||
|
"cursorKind": "sequence",
|
||||||
|
"retentionDays": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "latest-log",
|
||||||
|
"kind": "file.tail",
|
||||||
|
"targetKey": "logs/latest",
|
||||||
|
"streamKey": "latest-log",
|
||||||
|
"cursorKind": "fingerprint",
|
||||||
|
"retentionDays": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transportProfiles": [
|
||||||
|
{
|
||||||
|
"key": "server-files",
|
||||||
|
"kind": "file",
|
||||||
|
"targetKey": "server-root",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.files.read",
|
||||||
|
"remote.run.files.write",
|
||||||
|
"remote.run.logs.transfer"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rcon",
|
||||||
|
"kind": "rcon",
|
||||||
|
"targetKey": "rcon",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.rcon.command"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "serverName",
|
||||||
|
"label": "服务器名称",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gamePort",
|
||||||
|
"label": "游戏端口",
|
||||||
|
"type": "port",
|
||||||
|
"required": true,
|
||||||
|
"default": 25565
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rconPort",
|
||||||
|
"label": "RCON 端口",
|
||||||
|
"type": "port",
|
||||||
|
"required": true,
|
||||||
|
"default": 25575
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,11 +5,19 @@
|
|||||||
"description": "First-party local SCUM game server management plugin for platform-mediated lifecycle proof.",
|
"description": "First-party local SCUM game server management plugin for platform-mediated lifecycle proof.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"kind": "game-plugin",
|
"kind": "game-plugin",
|
||||||
"tags": ["scum", "survival", "dedicated-server", "local-proof"],
|
"tags": [
|
||||||
|
"scum",
|
||||||
|
"survival",
|
||||||
|
"dedicated-server",
|
||||||
|
"local-proof"
|
||||||
|
],
|
||||||
"server": {
|
"server": {
|
||||||
"type": "scum",
|
"type": "scum",
|
||||||
"displayName": "SCUM Dedicated Server",
|
"displayName": "SCUM Dedicated Server",
|
||||||
"supportedOS": ["windows", "linux"],
|
"supportedOS": [
|
||||||
|
"windows",
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
"createFormSchema": "schemas/create-form.schema.json"
|
"createFormSchema": "schemas/create-form.schema.json"
|
||||||
},
|
},
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
@@ -22,10 +30,45 @@
|
|||||||
"files.read",
|
"files.read",
|
||||||
"files.patch",
|
"files.patch",
|
||||||
"logs.read",
|
"logs.read",
|
||||||
|
"remote.ftp.read",
|
||||||
|
"remote.ftp.write",
|
||||||
|
"remote.rsync.read",
|
||||||
|
"remote.rsync.write",
|
||||||
|
"remote.run.files.read",
|
||||||
|
"remote.run.files.write",
|
||||||
|
"remote.run.process.start",
|
||||||
|
"remote.run.process.stop",
|
||||||
|
"remote.run.db.mysql.query",
|
||||||
|
"remote.run.db.sqlite.query",
|
||||||
|
"remote.run.logs.transfer",
|
||||||
|
"remote.run.rcon.command",
|
||||||
"artifacts.read",
|
"artifacts.read",
|
||||||
"artifacts.write",
|
"artifacts.write",
|
||||||
"ai.invoke"
|
"ai.invoke"
|
||||||
],
|
],
|
||||||
|
"remoteAccess": {
|
||||||
|
"methods": [
|
||||||
|
"ftp",
|
||||||
|
"rsync",
|
||||||
|
"run"
|
||||||
|
],
|
||||||
|
"runCapabilities": [
|
||||||
|
"remote.run.files.read",
|
||||||
|
"remote.run.files.write",
|
||||||
|
"remote.run.process.start",
|
||||||
|
"remote.run.process.stop",
|
||||||
|
"remote.run.db.mysql.query",
|
||||||
|
"remote.run.db.sqlite.query",
|
||||||
|
"remote.run.logs.transfer",
|
||||||
|
"remote.run.rcon.command"
|
||||||
|
],
|
||||||
|
"databaseEngines": [
|
||||||
|
"mysql",
|
||||||
|
"sqlite"
|
||||||
|
],
|
||||||
|
"rcon": true,
|
||||||
|
"logTransfer": true
|
||||||
|
},
|
||||||
"bridge": {
|
"bridge": {
|
||||||
"actions": [
|
"actions": [
|
||||||
"server.instances.read",
|
"server.instances.read",
|
||||||
@@ -33,7 +76,12 @@
|
|||||||
"logs.query",
|
"logs.query",
|
||||||
"artifacts.open",
|
"artifacts.open",
|
||||||
"files.request",
|
"files.request",
|
||||||
"ai.invoke"
|
"remote.access.request",
|
||||||
|
"ai.invoke",
|
||||||
|
"run.distribution.request",
|
||||||
|
"dependencies.request",
|
||||||
|
"logs.backfill.request",
|
||||||
|
"client-manager.request"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -45,7 +93,11 @@
|
|||||||
"server.logs.read",
|
"server.logs.read",
|
||||||
"server.artifacts.read",
|
"server.artifacts.read",
|
||||||
"server.artifacts.write",
|
"server.artifacts.write",
|
||||||
"ai.invoke"
|
"server.remote.access",
|
||||||
|
"ai.invoke",
|
||||||
|
"server.run.distribution",
|
||||||
|
"server.dependencies.manage",
|
||||||
|
"server.client-manager.manage"
|
||||||
],
|
],
|
||||||
"actions": {
|
"actions": {
|
||||||
"install": "actions/install.json",
|
"install": "actions/install.json",
|
||||||
@@ -59,25 +111,308 @@
|
|||||||
"key": "overview",
|
"key": "overview",
|
||||||
"title": "SCUM 概览",
|
"title": "SCUM 概览",
|
||||||
"path": "/overview",
|
"path": "/overview",
|
||||||
"permissions": ["server.read", "server.lifecycle"],
|
"permissions": [
|
||||||
"bridgeActions": ["server.instances.read", "jobs.dispatch"]
|
"server.read",
|
||||||
|
"server.lifecycle"
|
||||||
|
],
|
||||||
|
"bridgeActions": [
|
||||||
|
"server.instances.read",
|
||||||
|
"jobs.dispatch"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "config",
|
"key": "config",
|
||||||
"title": "SCUM 配置",
|
"title": "SCUM 配置",
|
||||||
"path": "/config",
|
"path": "/config",
|
||||||
"permissions": ["server.files.read", "server.files.write", "ai.invoke"],
|
"permissions": [
|
||||||
"bridgeActions": ["files.request", "ai.invoke"]
|
"server.files.read",
|
||||||
|
"server.files.write",
|
||||||
|
"ai.invoke"
|
||||||
|
],
|
||||||
|
"bridgeActions": [
|
||||||
|
"files.request",
|
||||||
|
"ai.invoke"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "logs",
|
"key": "logs",
|
||||||
"title": "SCUM 日志",
|
"title": "SCUM 日志",
|
||||||
"path": "/logs",
|
"path": "/logs",
|
||||||
"permissions": ["server.logs.read", "server.artifacts.read", "ai.invoke"],
|
"permissions": [
|
||||||
"bridgeActions": ["logs.query", "artifacts.open", "ai.invoke"]
|
"server.logs.read",
|
||||||
|
"server.artifacts.read",
|
||||||
|
"server.remote.access",
|
||||||
|
"ai.invoke"
|
||||||
|
],
|
||||||
|
"bridgeActions": [
|
||||||
|
"logs.query",
|
||||||
|
"artifacts.open",
|
||||||
|
"remote.access.request",
|
||||||
|
"ai.invoke"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "remote",
|
||||||
|
"title": "SCUM 远程",
|
||||||
|
"path": "/remote",
|
||||||
|
"permissions": [
|
||||||
|
"server.remote.access",
|
||||||
|
"server.files.read",
|
||||||
|
"server.files.write",
|
||||||
|
"server.lifecycle"
|
||||||
|
],
|
||||||
|
"bridgeActions": [
|
||||||
|
"remote.access.request",
|
||||||
|
"files.request",
|
||||||
|
"jobs.dispatch"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ai": {
|
"ai": {
|
||||||
"purposes": ["config.suggest", "logs.diagnose"]
|
"purposes": [
|
||||||
|
"config.suggest",
|
||||||
|
"logs.diagnose"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runtimeProfiles": {
|
||||||
|
"discovery": [
|
||||||
|
{
|
||||||
|
"key": "steamcmd",
|
||||||
|
"kind": "command.version",
|
||||||
|
"targetKey": "steamcmd",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-install",
|
||||||
|
"kind": "file.exists",
|
||||||
|
"targetKey": "server/install-root",
|
||||||
|
"required": true,
|
||||||
|
"platforms": [
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lifecycleProfiles": [
|
||||||
|
{
|
||||||
|
"key": "run-local",
|
||||||
|
"mode": "local-process",
|
||||||
|
"capabilities": [
|
||||||
|
"process.install",
|
||||||
|
"process.start",
|
||||||
|
"process.stop",
|
||||||
|
"process.restart",
|
||||||
|
"process.status",
|
||||||
|
"remote.run.process.start",
|
||||||
|
"remote.run.process.stop"
|
||||||
|
],
|
||||||
|
"actionRefs": {
|
||||||
|
"install": "actions/install.json",
|
||||||
|
"start": "actions/start.json",
|
||||||
|
"stop": "actions/stop.json",
|
||||||
|
"restart": "actions/restart.json",
|
||||||
|
"status": "actions/status.json"
|
||||||
|
},
|
||||||
|
"transportKeys": [
|
||||||
|
"server-files",
|
||||||
|
"sqlite-db",
|
||||||
|
"mysql-db",
|
||||||
|
"rcon"
|
||||||
|
],
|
||||||
|
"platforms": [
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hosted-ftp",
|
||||||
|
"mode": "hosted-ftp-rcon",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.ftp.read",
|
||||||
|
"remote.ftp.write",
|
||||||
|
"remote.run.logs.transfer",
|
||||||
|
"remote.run.rcon.command"
|
||||||
|
],
|
||||||
|
"transportKeys": [
|
||||||
|
"ftp",
|
||||||
|
"rcon"
|
||||||
|
],
|
||||||
|
"platforms": [
|
||||||
|
"windows",
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-client",
|
||||||
|
"mode": "custom-client",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.rcon.command",
|
||||||
|
"remote.run.logs.transfer"
|
||||||
|
],
|
||||||
|
"transportKeys": [
|
||||||
|
"client-rcon"
|
||||||
|
],
|
||||||
|
"clientManagerRef": "scum-client-manager",
|
||||||
|
"platforms": [
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencyProbes": [
|
||||||
|
{
|
||||||
|
"key": "steamcmd",
|
||||||
|
"kind": "command.version",
|
||||||
|
"targetKey": "steamcmd",
|
||||||
|
"required": true,
|
||||||
|
"platforms": [
|
||||||
|
"windows",
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "sqlite",
|
||||||
|
"kind": "package.installed",
|
||||||
|
"targetKey": "sqlite-driver",
|
||||||
|
"required": false,
|
||||||
|
"platforms": [
|
||||||
|
"windows",
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"installPlans": [
|
||||||
|
{
|
||||||
|
"key": "install-steamcmd-linux",
|
||||||
|
"title": "Install SteamCMD",
|
||||||
|
"platforms": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "package",
|
||||||
|
"targetKey": "steamcmd",
|
||||||
|
"packageManager": "apt",
|
||||||
|
"packageName": "steamcmd"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"logSources": [
|
||||||
|
{
|
||||||
|
"key": "chat-log",
|
||||||
|
"kind": "ftp.poll",
|
||||||
|
"targetKey": "logs/chat",
|
||||||
|
"streamKey": "chat",
|
||||||
|
"cursorKind": "ftp-listing",
|
||||||
|
"retentionDays": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "server-log",
|
||||||
|
"kind": "file.tail",
|
||||||
|
"targetKey": "logs/server",
|
||||||
|
"streamKey": "server",
|
||||||
|
"cursorKind": "fingerprint",
|
||||||
|
"retentionDays": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "client-manager",
|
||||||
|
"kind": "client-manager",
|
||||||
|
"targetKey": "scum-client-manager",
|
||||||
|
"streamKey": "client-manager",
|
||||||
|
"cursorKind": "sequence",
|
||||||
|
"retentionDays": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transportProfiles": [
|
||||||
|
{
|
||||||
|
"key": "server-files",
|
||||||
|
"kind": "file",
|
||||||
|
"targetKey": "server-root",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.files.read",
|
||||||
|
"remote.run.files.write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ftp",
|
||||||
|
"kind": "ftp",
|
||||||
|
"targetKey": "ftp-root",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.ftp.read",
|
||||||
|
"remote.ftp.write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rsync",
|
||||||
|
"kind": "rsync",
|
||||||
|
"targetKey": "rsync-root",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.rsync.read",
|
||||||
|
"remote.rsync.write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "sqlite-db",
|
||||||
|
"kind": "sqlite",
|
||||||
|
"targetKey": "db/sqlite",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.db.sqlite.query"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mysql-db",
|
||||||
|
"kind": "mysql",
|
||||||
|
"targetKey": "db/mysql",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.db.mysql.query"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rcon",
|
||||||
|
"kind": "rcon",
|
||||||
|
"targetKey": "rcon",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.rcon.command"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "client-rcon",
|
||||||
|
"kind": "rcon",
|
||||||
|
"targetKey": "client/rcon",
|
||||||
|
"capabilities": [
|
||||||
|
"remote.run.rcon.command"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clientManagers": [
|
||||||
|
{
|
||||||
|
"key": "scum-client-manager",
|
||||||
|
"displayName": "SCUM Client Manager",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/F88888/scum_client.git",
|
||||||
|
"revisionPolicy": "branch",
|
||||||
|
"branch": "main"
|
||||||
|
},
|
||||||
|
"supportedTargets": [
|
||||||
|
{
|
||||||
|
"os": "windows",
|
||||||
|
"arch": "amd64"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"system": "go",
|
||||||
|
"workspaceRef": "scum_client",
|
||||||
|
"entryRef": "main.go"
|
||||||
|
},
|
||||||
|
"configTemplates": [
|
||||||
|
{
|
||||||
|
"key": "client-config",
|
||||||
|
"templateRef": "configs/client.template.json",
|
||||||
|
"outputRef": "config.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputArtifacts": [
|
||||||
|
"scum_client.exe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,72 @@
|
|||||||
"items": { "$ref": "#/$defs/pluginPermission" },
|
"items": { "$ref": "#/$defs/pluginPermission" },
|
||||||
"uniqueItems": true
|
"uniqueItems": true
|
||||||
},
|
},
|
||||||
|
"remoteAccess": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["methods"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"methods": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/remoteAccessMethod" },
|
||||||
|
"uniqueItems": true,
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"runCapabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/runCapability" },
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"databaseEngines": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/remoteDatabaseEngine" },
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"rcon": { "type": "boolean" },
|
||||||
|
"logTransfer": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeProfiles": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"discovery": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/runtimeDiscoveryProbe" },
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"lifecycleProfiles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/runtimeLifecycleProfile" },
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"dependencyProbes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/runtimeDependencyProbe" },
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"installPlans": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/runtimeInstallPlan" },
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"logSources": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/runtimeLogSource" },
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"transportProfiles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/runtimeTransportProfile" },
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"clientManagers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/runtimeClientManagerProfile" },
|
||||||
|
"uniqueItems": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["install", "start", "stop"],
|
"required": ["install", "start", "stop"],
|
||||||
@@ -104,6 +170,18 @@
|
|||||||
"files.write",
|
"files.write",
|
||||||
"files.patch",
|
"files.patch",
|
||||||
"logs.read",
|
"logs.read",
|
||||||
|
"remote.ftp.read",
|
||||||
|
"remote.ftp.write",
|
||||||
|
"remote.rsync.read",
|
||||||
|
"remote.rsync.write",
|
||||||
|
"remote.run.files.read",
|
||||||
|
"remote.run.files.write",
|
||||||
|
"remote.run.process.start",
|
||||||
|
"remote.run.process.stop",
|
||||||
|
"remote.run.db.mysql.query",
|
||||||
|
"remote.run.db.sqlite.query",
|
||||||
|
"remote.run.logs.transfer",
|
||||||
|
"remote.run.rcon.command",
|
||||||
"artifacts.read",
|
"artifacts.read",
|
||||||
"artifacts.write",
|
"artifacts.write",
|
||||||
"ai.invoke"
|
"ai.invoke"
|
||||||
@@ -119,6 +197,10 @@
|
|||||||
"server.logs.read",
|
"server.logs.read",
|
||||||
"server.artifacts.read",
|
"server.artifacts.read",
|
||||||
"server.artifacts.write",
|
"server.artifacts.write",
|
||||||
|
"server.remote.access",
|
||||||
|
"server.run.distribution",
|
||||||
|
"server.dependencies.manage",
|
||||||
|
"server.client-manager.manage",
|
||||||
"ai.invoke"
|
"ai.invoke"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -129,9 +211,191 @@
|
|||||||
"logs.query",
|
"logs.query",
|
||||||
"artifacts.open",
|
"artifacts.open",
|
||||||
"files.request",
|
"files.request",
|
||||||
|
"remote.access.request",
|
||||||
|
"run.distribution.request",
|
||||||
|
"dependencies.request",
|
||||||
|
"logs.backfill.request",
|
||||||
|
"client-manager.request",
|
||||||
"ai.invoke"
|
"ai.invoke"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"remoteAccessMethod": {
|
||||||
|
"enum": ["ftp", "rsync", "run"]
|
||||||
|
},
|
||||||
|
"remoteDatabaseEngine": {
|
||||||
|
"enum": ["mysql", "sqlite"]
|
||||||
|
},
|
||||||
|
"logicalKey": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9._/-]*$",
|
||||||
|
"maxLength": 120
|
||||||
|
},
|
||||||
|
"runtimePlatform": {
|
||||||
|
"enum": ["windows", "linux", "darwin"]
|
||||||
|
},
|
||||||
|
"runtimeArch": {
|
||||||
|
"enum": ["amd64", "arm64"]
|
||||||
|
},
|
||||||
|
"runtimeTarget": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["os", "arch"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"os": { "$ref": "#/$defs/runtimePlatform" },
|
||||||
|
"arch": { "$ref": "#/$defs/runtimeArch" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeDiscoveryProbe": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "kind", "targetKey"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"key": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"kind": { "enum": ["file.exists", "command.version", "service.status", "port.open", "steam.app", "docker.container"] },
|
||||||
|
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"required": { "type": "boolean" },
|
||||||
|
"expected": { "type": "string", "maxLength": 120 },
|
||||||
|
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeLifecycleProfile": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "mode", "capabilities"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"key": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"mode": { "enum": ["local-process", "hosted-ftp-rcon", "ftp-only", "custom-client"] },
|
||||||
|
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 },
|
||||||
|
"actionRefs": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"install": { "$ref": "#/$defs/relativeJsonRef" },
|
||||||
|
"start": { "$ref": "#/$defs/relativeJsonRef" },
|
||||||
|
"stop": { "$ref": "#/$defs/relativeJsonRef" },
|
||||||
|
"restart": { "$ref": "#/$defs/relativeJsonRef" },
|
||||||
|
"status": { "$ref": "#/$defs/relativeJsonRef" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transportKeys": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true },
|
||||||
|
"clientManagerRef": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeDependencyProbe": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "kind", "targetKey"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"key": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"kind": { "enum": ["command.version", "service.exists", "port.available", "steam.app", "java.version", "docker.available", "package.installed", "file.exists"] },
|
||||||
|
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"required": { "type": "boolean" },
|
||||||
|
"minimumVersion": { "type": "string", "maxLength": 80 },
|
||||||
|
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeInstallStep": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "targetKey"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"type": { "enum": ["package", "verified-download", "steamcmd-app", "manual"] },
|
||||||
|
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"packageManager": { "enum": ["winget", "choco", "scoop", "apt", "yum", "dnf", "pacman", "zypper", "brew", "steamcmd", "manual"] },
|
||||||
|
"packageName": { "type": "string", "pattern": "^[a-zA-Z0-9_.:+@/-]+$", "maxLength": 120 },
|
||||||
|
"version": { "type": "string", "maxLength": 80 },
|
||||||
|
"downloadRef": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+$", "maxLength": 240 },
|
||||||
|
"checksum": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeInstallPlan": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "title", "steps"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"key": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||||
|
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true },
|
||||||
|
"steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeLogSource": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "kind", "streamKey"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"key": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"kind": { "enum": ["process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager"] },
|
||||||
|
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"streamKey": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"cursorKind": { "enum": ["sequence", "offset", "fingerprint", "ftp-listing", "sql-cursor"] },
|
||||||
|
"retentionDays": { "type": "integer", "minimum": 1, "maximum": 365 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeTransportProfile": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "kind", "capabilities"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"key": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"kind": { "enum": ["file", "ftp", "rsync", "mysql", "sqlite", "rcon"] },
|
||||||
|
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relativePathRef": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+$",
|
||||||
|
"maxLength": 160
|
||||||
|
},
|
||||||
|
"runtimeClientManagerProfile": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "repository", "supportedTargets", "build", "outputArtifacts"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"key": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||||
|
"repository": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["url", "revisionPolicy"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"url": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+\\.git$", "maxLength": 240 },
|
||||||
|
"branch": { "type": "string", "pattern": "^[a-zA-Z0-9._/-]+$", "maxLength": 120 },
|
||||||
|
"tag": { "type": "string", "pattern": "^[a-zA-Z0-9._/-]+$", "maxLength": 120 },
|
||||||
|
"revision": { "type": "string", "pattern": "^[a-fA-F0-9]{7,64}$" },
|
||||||
|
"revisionPolicy": { "enum": ["pinned", "branch", "tag"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"supportedTargets": { "type": "array", "items": { "$ref": "#/$defs/runtimeTarget" }, "minItems": 1, "uniqueItems": true },
|
||||||
|
"build": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["system"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"system": { "enum": ["go", "npm", "cargo", "make"] },
|
||||||
|
"workspaceRef": { "$ref": "#/$defs/relativePathRef" },
|
||||||
|
"entryRef": { "$ref": "#/$defs/relativePathRef" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configTemplates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "templateRef", "outputRef"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"key": { "$ref": "#/$defs/logicalKey" },
|
||||||
|
"templateRef": { "$ref": "#/$defs/relativePathRef" },
|
||||||
|
"outputRef": { "$ref": "#/$defs/relativePathRef" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"outputArtifacts": { "type": "array", "items": { "$ref": "#/$defs/relativePathRef" }, "minItems": 1, "uniqueItems": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
"aiPurpose": {
|
"aiPurpose": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"config.read",
|
"config.read",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"validate:manifest": "tsx scripts/validate-manifest.ts examples/dev-game-plugin/manifest.json && tsx scripts/validate-manifest.ts examples/scum-server-plugin/manifest.json"
|
"validate:manifest": "tsx scripts/validate-manifest.ts examples/dev-game-plugin/manifest.json && tsx scripts/validate-manifest.ts examples/scum-server-plugin/manifest.json && tsx scripts/validate-manifest.ts examples/minecraft-server-plugin/manifest.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "8.18.0"
|
"ajv": "8.18.0"
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ function unsafeFieldReason(fieldName: string): string | undefined {
|
|||||||
if (compact.includes("runcredential") || compact.includes("runsocket") || compact.includes("directrun")) {
|
if (compact.includes("runcredential") || compact.includes("runsocket") || compact.includes("directrun")) {
|
||||||
return "direct run access field is not allowed";
|
return "direct run access field is not allowed";
|
||||||
}
|
}
|
||||||
|
if (compact.includes("password") || compact.includes("dsn") || compact.includes("rawkey") || compact.includes("secretkey") || compact.includes("credential")) {
|
||||||
|
return "raw credential field is not allowed";
|
||||||
|
}
|
||||||
if (compact.includes("hostpath") || compact.includes("rawpath")) {
|
if (compact.includes("hostpath") || compact.includes("rawpath")) {
|
||||||
return "raw host path field is not allowed";
|
return "raw host path field is not allowed";
|
||||||
}
|
}
|
||||||
@@ -47,7 +50,13 @@ function unsafeStringReasons(value: string): string[] {
|
|||||||
lowered.includes("raw api key") ||
|
lowered.includes("raw api key") ||
|
||||||
lowered.includes("raw credential") ||
|
lowered.includes("raw credential") ||
|
||||||
lowered.includes("provider key") ||
|
lowered.includes("provider key") ||
|
||||||
lowered.includes("ai key")
|
lowered.includes("ai key") ||
|
||||||
|
lowered.includes("password=") ||
|
||||||
|
lowered.includes("rcon password") ||
|
||||||
|
lowered.includes("ftp password") ||
|
||||||
|
lowered.startsWith("mysql://") ||
|
||||||
|
lowered.startsWith("sqlite://") ||
|
||||||
|
lowered.includes("database dsn")
|
||||||
) {
|
) {
|
||||||
reasons.push("raw credential or AI/provider key content is not allowed");
|
reasons.push("raw credential or AI/provider key content is not allowed");
|
||||||
}
|
}
|
||||||
@@ -56,7 +65,9 @@ function unsafeStringReasons(value: string): string[] {
|
|||||||
lowered.includes("run socket") ||
|
lowered.includes("run socket") ||
|
||||||
lowered.includes("run credential") ||
|
lowered.includes("run credential") ||
|
||||||
lowered.includes("run token") ||
|
lowered.includes("run token") ||
|
||||||
lowered.includes("direct socket")
|
lowered.includes("direct socket") ||
|
||||||
|
lowered.startsWith("tcp://") ||
|
||||||
|
lowered.startsWith("unix://")
|
||||||
) {
|
) {
|
||||||
reasons.push("direct run access request is not allowed");
|
reasons.push("direct run access request is not allowed");
|
||||||
}
|
}
|
||||||
@@ -70,6 +81,15 @@ function unsafeStringReasons(value: string): string[] {
|
|||||||
) {
|
) {
|
||||||
reasons.push("raw host path access is not allowed");
|
reasons.push("raw host path access is not allowed");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
lowered.includes("shell script") ||
|
||||||
|
lowered.includes("bash -c") ||
|
||||||
|
lowered.includes("powershell -") ||
|
||||||
|
lowered.includes("cmd.exe") ||
|
||||||
|
lowered.includes("curl |")
|
||||||
|
) {
|
||||||
|
reasons.push("arbitrary shell content is not allowed");
|
||||||
|
}
|
||||||
return reasons;
|
return reasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ Plugins use the platform bridge for every privileged action.
|
|||||||
- `logs.query`: query historical logs by server, stream, time range, cursor, or analysis window.
|
- `logs.query`: query historical logs by server, stream, time range, cursor, or analysis window.
|
||||||
- `artifacts.open`: request platform-mediated artifact download references.
|
- `artifacts.open`: request platform-mediated artifact download references.
|
||||||
- `files.request`: request scoped file list/read/patch/replace operations through platform jobs.
|
- `files.request`: request scoped file list/read/patch/replace operations through platform jobs.
|
||||||
|
- `remote.access.request`: request plugin-declared FTP, rsync, or run-mediated remote operations through platform jobs.
|
||||||
|
- `run.distribution.request`: request platform-mediated run package generation, download, key reset, or self-update orchestration.
|
||||||
|
- `dependencies.request`: request typed dependency checks or approved install plans declared by the plugin runtime profile.
|
||||||
|
- `logs.backfill.request`: request historical log backfill for a declared log source.
|
||||||
|
- `client-manager.request`: request generation, download, or key reset for a plugin-declared companion client manager.
|
||||||
- `ai.invoke`: request platform-mediated AI assistance.
|
- `ai.invoke`: request platform-mediated AI assistance.
|
||||||
- `theme.tokens`: read safe platform theme tokens.
|
- `theme.tokens`: read safe platform theme tokens.
|
||||||
|
|
||||||
@@ -22,6 +27,10 @@ AI requests use `createAIInvocationRequest` with an explicit purpose, prompt, sc
|
|||||||
|
|
||||||
Artifact open requests use `createArtifactOpenRequest` with an artifact ID that belongs to the current server/job scope. Use `parseArtifactReference` to consume the bridge result. Parsed references contain platform-owned download URLs, filename, content type, size, checksum, expiry, range support, and chunk size; they do not contain bytes or raw storage adapter locations.
|
Artifact open requests use `createArtifactOpenRequest` with an artifact ID that belongs to the current server/job scope. Use `parseArtifactReference` to consume the bridge result. Parsed references contain platform-owned download URLs, filename, content type, size, checksum, expiry, range support, and chunk size; they do not contain bytes or raw storage adapter locations.
|
||||||
|
|
||||||
|
Remote access requests use `createRemoteAccessRequest` with a plugin-declared `remote.*` capability, logical target key, optional scoped `input://` or `artifact://` ref, and idempotency key. The SDK never accepts FTP passwords, rsync endpoints, database DSNs, RCON passwords, run sockets, or raw host paths in these envelopes.
|
||||||
|
|
||||||
|
Run distribution, dependency, log backfill, and client-manager requests use `createRunDistributionRequest`, `createDependencyActionRequest`, `createLogBackfillRequest`, and `createClientManagerRequest`. These helpers carry operation names, logical profile keys, target OS/architecture, artifact IDs, cursors, and idempotency keys only; raw run keys and client-manager keys are written only into generated packages by platform services.
|
||||||
|
|
||||||
## Forbidden Data
|
## Forbidden Data
|
||||||
|
|
||||||
The bridge must not expose:
|
The bridge must not expose:
|
||||||
@@ -33,3 +42,4 @@ The bridge must not expose:
|
|||||||
- storage backend endpoints.
|
- storage backend endpoints.
|
||||||
- unrestricted artifact storage credentials.
|
- unrestricted artifact storage credentials.
|
||||||
- direct storage URLs or presigned backend URLs.
|
- direct storage URLs or presigned backend URLs.
|
||||||
|
- FTP, rsync, database, or RCON credentials.
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export type PluginPermission =
|
|||||||
| "server.logs.read"
|
| "server.logs.read"
|
||||||
| "server.artifacts.read"
|
| "server.artifacts.read"
|
||||||
| "server.artifacts.write"
|
| "server.artifacts.write"
|
||||||
|
| "server.remote.access"
|
||||||
|
| "server.run.distribution"
|
||||||
|
| "server.dependencies.manage"
|
||||||
|
| "server.client-manager.manage"
|
||||||
| "ai.invoke";
|
| "ai.invoke";
|
||||||
|
|
||||||
export type RunCapability =
|
export type RunCapability =
|
||||||
@@ -20,6 +24,18 @@ export type RunCapability =
|
|||||||
| "files.write"
|
| "files.write"
|
||||||
| "files.patch"
|
| "files.patch"
|
||||||
| "logs.read"
|
| "logs.read"
|
||||||
|
| "remote.ftp.read"
|
||||||
|
| "remote.ftp.write"
|
||||||
|
| "remote.rsync.read"
|
||||||
|
| "remote.rsync.write"
|
||||||
|
| "remote.run.files.read"
|
||||||
|
| "remote.run.files.write"
|
||||||
|
| "remote.run.process.start"
|
||||||
|
| "remote.run.process.stop"
|
||||||
|
| "remote.run.db.mysql.query"
|
||||||
|
| "remote.run.db.sqlite.query"
|
||||||
|
| "remote.run.logs.transfer"
|
||||||
|
| "remote.run.rcon.command"
|
||||||
| "artifacts.read"
|
| "artifacts.read"
|
||||||
| "artifacts.write"
|
| "artifacts.write"
|
||||||
| "ai.invoke";
|
| "ai.invoke";
|
||||||
@@ -32,6 +48,11 @@ export type PluginBridgeAction =
|
|||||||
| "logs.query"
|
| "logs.query"
|
||||||
| "artifacts.open"
|
| "artifacts.open"
|
||||||
| "files.request"
|
| "files.request"
|
||||||
|
| "remote.access.request"
|
||||||
|
| "run.distribution.request"
|
||||||
|
| "dependencies.request"
|
||||||
|
| "logs.backfill.request"
|
||||||
|
| "client-manager.request"
|
||||||
| "ai.invoke";
|
| "ai.invoke";
|
||||||
|
|
||||||
export type PluginBridgeRequestPayload = Record<string, unknown>;
|
export type PluginBridgeRequestPayload = Record<string, unknown>;
|
||||||
@@ -105,6 +126,150 @@ export type PluginLifecycleDispatchPayload = Record<string, string> & {
|
|||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginRemoteAccessPayload = Record<string, string> & {
|
||||||
|
capability: Extract<RunCapability, `remote.${string}`>;
|
||||||
|
targetKey?: string;
|
||||||
|
inputRef?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginRunDistributionPayload = Record<string, string> & {
|
||||||
|
operation: "generate" | "download" | "reset-key" | "update";
|
||||||
|
targetOS?: RuntimePlatform;
|
||||||
|
targetArch?: RuntimeArch;
|
||||||
|
artifactId?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginDependencyActionPayload = Record<string, string> & {
|
||||||
|
operation: "check" | "install";
|
||||||
|
probeKey?: string;
|
||||||
|
planKey?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginLogBackfillPayload = Record<string, string> & {
|
||||||
|
sourceKey: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginClientManagerPayload = Record<string, string> & {
|
||||||
|
operation: "generate" | "download" | "reset-key";
|
||||||
|
profileKey: string;
|
||||||
|
targetOS?: RuntimePlatform;
|
||||||
|
targetArch?: RuntimeArch;
|
||||||
|
artifactId?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoteAccessMethod = "ftp" | "rsync" | "run";
|
||||||
|
export type RemoteDatabaseEngine = "mysql" | "sqlite";
|
||||||
|
|
||||||
|
export interface GamePluginRemoteAccess {
|
||||||
|
methods: RemoteAccessMethod[];
|
||||||
|
runCapabilities?: Array<Extract<RunCapability, `remote.${string}`>>;
|
||||||
|
databaseEngines?: RemoteDatabaseEngine[];
|
||||||
|
rcon?: boolean;
|
||||||
|
logTransfer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimePlatform = "windows" | "linux" | "darwin";
|
||||||
|
export type RuntimeArch = "amd64" | "arm64";
|
||||||
|
export type RuntimeTarget = { os: RuntimePlatform; arch: RuntimeArch };
|
||||||
|
|
||||||
|
export interface RuntimeDiscoveryProbe {
|
||||||
|
key: string;
|
||||||
|
kind: "file.exists" | "command.version" | "service.status" | "port.open" | "steam.app" | "docker.container";
|
||||||
|
targetKey: string;
|
||||||
|
required?: boolean;
|
||||||
|
expected?: string;
|
||||||
|
platforms?: RuntimePlatform[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeLifecycleProfile {
|
||||||
|
key: string;
|
||||||
|
mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client";
|
||||||
|
capabilities: RunCapability[];
|
||||||
|
actionRefs?: Partial<Record<PluginLifecycleAction, string>>;
|
||||||
|
transportKeys?: string[];
|
||||||
|
clientManagerRef?: string;
|
||||||
|
platforms?: RuntimePlatform[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDependencyProbe {
|
||||||
|
key: string;
|
||||||
|
kind: "command.version" | "service.exists" | "port.available" | "steam.app" | "java.version" | "docker.available" | "package.installed" | "file.exists";
|
||||||
|
targetKey: string;
|
||||||
|
required?: boolean;
|
||||||
|
minimumVersion?: string;
|
||||||
|
platforms?: RuntimePlatform[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeInstallStep {
|
||||||
|
type: "package" | "verified-download" | "steamcmd-app" | "manual";
|
||||||
|
targetKey: string;
|
||||||
|
packageManager?: "winget" | "choco" | "scoop" | "apt" | "yum" | "dnf" | "pacman" | "zypper" | "brew" | "steamcmd" | "manual";
|
||||||
|
packageName?: string;
|
||||||
|
version?: string;
|
||||||
|
downloadRef?: string;
|
||||||
|
checksum?: `sha256:${string}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeInstallPlan {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
platforms?: RuntimePlatform[];
|
||||||
|
steps: RuntimeInstallStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeLogSource {
|
||||||
|
key: string;
|
||||||
|
kind: "process.stdout" | "process.stderr" | "file.tail" | "ftp.poll" | "sql.query" | "client-manager";
|
||||||
|
targetKey?: string;
|
||||||
|
streamKey: string;
|
||||||
|
cursorKind?: "sequence" | "offset" | "fingerprint" | "ftp-listing" | "sql-cursor";
|
||||||
|
retentionDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeTransportProfile {
|
||||||
|
key: string;
|
||||||
|
kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon";
|
||||||
|
targetKey?: string;
|
||||||
|
capabilities: RunCapability[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeClientManagerProfile {
|
||||||
|
key: string;
|
||||||
|
displayName?: string;
|
||||||
|
repository: {
|
||||||
|
url: string;
|
||||||
|
revisionPolicy: "pinned" | "branch" | "tag";
|
||||||
|
branch?: string;
|
||||||
|
tag?: string;
|
||||||
|
revision?: string;
|
||||||
|
};
|
||||||
|
supportedTargets: RuntimeTarget[];
|
||||||
|
build: {
|
||||||
|
system: "go" | "npm" | "cargo" | "make";
|
||||||
|
workspaceRef?: string;
|
||||||
|
entryRef?: string;
|
||||||
|
};
|
||||||
|
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
|
||||||
|
outputArtifacts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePluginRuntimeProfiles {
|
||||||
|
discovery?: RuntimeDiscoveryProbe[];
|
||||||
|
lifecycleProfiles?: RuntimeLifecycleProfile[];
|
||||||
|
dependencyProbes?: RuntimeDependencyProbe[];
|
||||||
|
installPlans?: RuntimeInstallPlan[];
|
||||||
|
logSources?: RuntimeLogSource[];
|
||||||
|
transportProfiles?: RuntimeTransportProfile[];
|
||||||
|
clientManagers?: RuntimeClientManagerProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginArtifactReference {
|
export interface PluginArtifactReference {
|
||||||
artifactId: string;
|
artifactId: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -140,6 +305,11 @@ export const pluginBridgeActionPolicies: Record<PluginBridgeAction, PluginBridge
|
|||||||
"logs.query": { permissions: ["server.logs.read"] },
|
"logs.query": { permissions: ["server.logs.read"] },
|
||||||
"artifacts.open": { permissions: ["server.artifacts.read"] },
|
"artifacts.open": { permissions: ["server.artifacts.read"] },
|
||||||
"files.request": { permissions: ["server.files.read"] },
|
"files.request": { permissions: ["server.files.read"] },
|
||||||
|
"remote.access.request": { permissions: ["server.remote.access"] },
|
||||||
|
"run.distribution.request": { permissions: ["server.run.distribution"] },
|
||||||
|
"dependencies.request": { permissions: ["server.dependencies.manage"] },
|
||||||
|
"logs.backfill.request": { permissions: ["server.logs.read"] },
|
||||||
|
"client-manager.request": { permissions: ["server.client-manager.manage"] },
|
||||||
"ai.invoke": { permissions: ["ai.invoke"], aiPurposeRequired: true }
|
"ai.invoke": { permissions: ["ai.invoke"], aiPurposeRequired: true }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,6 +349,8 @@ export interface GamePluginManifest {
|
|||||||
bridge?: GamePluginBridge;
|
bridge?: GamePluginBridge;
|
||||||
capabilities: RunCapability[];
|
capabilities: RunCapability[];
|
||||||
permissions: PluginPermission[];
|
permissions: PluginPermission[];
|
||||||
|
remoteAccess?: GamePluginRemoteAccess;
|
||||||
|
runtimeProfiles?: GamePluginRuntimeProfiles;
|
||||||
actions?: GamePluginActions;
|
actions?: GamePluginActions;
|
||||||
pages?: GamePluginPage[];
|
pages?: GamePluginPage[];
|
||||||
ai?: {
|
ai?: {
|
||||||
@@ -280,6 +452,127 @@ export function createLifecycleDispatchRequest(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createRemoteAccessRequest(input: {
|
||||||
|
requestId: string;
|
||||||
|
context: PluginBridgeContext;
|
||||||
|
capability: PluginRemoteAccessPayload["capability"];
|
||||||
|
targetKey?: string;
|
||||||
|
inputRef?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}): PluginBridgeExecutionRequest<PluginRemoteAccessPayload> {
|
||||||
|
return createBridgeExecutionRequest({
|
||||||
|
requestId: input.requestId,
|
||||||
|
context: input.context,
|
||||||
|
action: "remote.access.request",
|
||||||
|
payload: {
|
||||||
|
capability: input.capability,
|
||||||
|
targetKey: input.targetKey ?? "",
|
||||||
|
inputRef: input.inputRef ?? "",
|
||||||
|
idempotencyKey: input.idempotencyKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRunDistributionRequest(input: {
|
||||||
|
requestId: string;
|
||||||
|
context: PluginBridgeContext;
|
||||||
|
operation: PluginRunDistributionPayload["operation"];
|
||||||
|
targetOS?: RuntimePlatform;
|
||||||
|
targetArch?: RuntimeArch;
|
||||||
|
artifactId?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}): PluginBridgeExecutionRequest<PluginRunDistributionPayload> {
|
||||||
|
const payload: PluginRunDistributionPayload = {
|
||||||
|
operation: input.operation,
|
||||||
|
artifactId: input.artifactId ?? "",
|
||||||
|
idempotencyKey: input.idempotencyKey
|
||||||
|
};
|
||||||
|
if (input.targetOS) {
|
||||||
|
payload.targetOS = input.targetOS;
|
||||||
|
}
|
||||||
|
if (input.targetArch) {
|
||||||
|
payload.targetArch = input.targetArch;
|
||||||
|
}
|
||||||
|
return createBridgeExecutionRequest({
|
||||||
|
requestId: input.requestId,
|
||||||
|
context: input.context,
|
||||||
|
action: "run.distribution.request",
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDependencyActionRequest(input: {
|
||||||
|
requestId: string;
|
||||||
|
context: PluginBridgeContext;
|
||||||
|
operation: PluginDependencyActionPayload["operation"];
|
||||||
|
probeKey?: string;
|
||||||
|
planKey?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}): PluginBridgeExecutionRequest<PluginDependencyActionPayload> {
|
||||||
|
return createBridgeExecutionRequest({
|
||||||
|
requestId: input.requestId,
|
||||||
|
context: input.context,
|
||||||
|
action: "dependencies.request",
|
||||||
|
payload: {
|
||||||
|
operation: input.operation,
|
||||||
|
probeKey: input.probeKey ?? "",
|
||||||
|
planKey: input.planKey ?? "",
|
||||||
|
idempotencyKey: input.idempotencyKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogBackfillRequest(input: {
|
||||||
|
requestId: string;
|
||||||
|
context: PluginBridgeContext;
|
||||||
|
sourceKey: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}): PluginBridgeExecutionRequest<PluginLogBackfillPayload> {
|
||||||
|
return createBridgeExecutionRequest({
|
||||||
|
requestId: input.requestId,
|
||||||
|
context: input.context,
|
||||||
|
action: "logs.backfill.request",
|
||||||
|
payload: {
|
||||||
|
sourceKey: input.sourceKey,
|
||||||
|
cursor: input.cursor ?? "",
|
||||||
|
limit: typeof input.limit === "number" ? String(input.limit) : "",
|
||||||
|
idempotencyKey: input.idempotencyKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClientManagerRequest(input: {
|
||||||
|
requestId: string;
|
||||||
|
context: PluginBridgeContext;
|
||||||
|
operation: PluginClientManagerPayload["operation"];
|
||||||
|
profileKey: string;
|
||||||
|
targetOS?: RuntimePlatform;
|
||||||
|
targetArch?: RuntimeArch;
|
||||||
|
artifactId?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}): PluginBridgeExecutionRequest<PluginClientManagerPayload> {
|
||||||
|
const payload: PluginClientManagerPayload = {
|
||||||
|
operation: input.operation,
|
||||||
|
profileKey: input.profileKey,
|
||||||
|
artifactId: input.artifactId ?? "",
|
||||||
|
idempotencyKey: input.idempotencyKey
|
||||||
|
};
|
||||||
|
if (input.targetOS) {
|
||||||
|
payload.targetOS = input.targetOS;
|
||||||
|
}
|
||||||
|
if (input.targetArch) {
|
||||||
|
payload.targetArch = input.targetArch;
|
||||||
|
}
|
||||||
|
return createBridgeExecutionRequest({
|
||||||
|
requestId: input.requestId,
|
||||||
|
context: input.context,
|
||||||
|
action: "client-manager.request",
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function parseArtifactReference(result: Record<string, string> | undefined): PluginArtifactReference | undefined {
|
export function parseArtifactReference(result: Record<string, string> | undefined): PluginArtifactReference | undefined {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../manifests/game-plugin.manifest.schema.json",
|
||||||
|
"id": "game.unsafe-runtime",
|
||||||
|
"name": "Unsafe Runtime Fixture",
|
||||||
|
"description": "Fixture with unsafe runtime profile values.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"kind": "game-plugin",
|
||||||
|
"server": {
|
||||||
|
"type": "unsafe-runtime",
|
||||||
|
"displayName": "Unsafe Runtime Fixture",
|
||||||
|
"createFormSchema": "create-form.valid.json"
|
||||||
|
},
|
||||||
|
"capabilities": ["process.start", "process.stop", "logs.read"],
|
||||||
|
"permissions": ["server.read", "server.lifecycle", "server.logs.read"],
|
||||||
|
"runtimeProfiles": {
|
||||||
|
"discovery": [
|
||||||
|
{"key": "leaky", "kind": "command.version", "targetKey": "java", "expected": "password=super-secret"}
|
||||||
|
],
|
||||||
|
"installPlans": [
|
||||||
|
{"key": "unsafe-install", "title": "bash -c installer", "steps": [{"type": "manual", "targetKey": "manual"}]}
|
||||||
|
],
|
||||||
|
"clientManagers": [
|
||||||
|
{
|
||||||
|
"key": "unsafe-client",
|
||||||
|
"repository": {"url": "https://github.com/F88888/scum_client.git", "revisionPolicy": "branch", "branch": "main"},
|
||||||
|
"supportedTargets": [{"os": "windows", "arch": "amd64"}],
|
||||||
|
"build": {"system": "go", "workspaceRef": "scum_client", "entryRef": "main.go"},
|
||||||
|
"configTemplates": [{"key": "bad", "templateRef": "/Users/tasia/client.json", "outputRef": "config.json"}],
|
||||||
|
"outputArtifacts": ["scum_client.exe"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"install": "actions/install.json",
|
||||||
|
"start": "actions/start.json",
|
||||||
|
"stop": "actions/stop.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,19 @@ import {
|
|||||||
canRequestBridgeAction,
|
canRequestBridgeAction,
|
||||||
createAIInvocationRequest,
|
createAIInvocationRequest,
|
||||||
createArtifactOpenRequest,
|
createArtifactOpenRequest,
|
||||||
|
createClientManagerRequest,
|
||||||
createBridgeExecutionRequest,
|
createBridgeExecutionRequest,
|
||||||
createLifecycleDispatchRequest,
|
createLifecycleDispatchRequest,
|
||||||
createBridgeRequest,
|
createBridgeRequest,
|
||||||
|
createDependencyActionRequest,
|
||||||
|
createLogBackfillRequest,
|
||||||
|
createRemoteAccessRequest,
|
||||||
|
createRunDistributionRequest,
|
||||||
hasPluginPermission,
|
hasPluginPermission,
|
||||||
parseArtifactReference,
|
parseArtifactReference,
|
||||||
parseBridgeExecutionResponse,
|
parseBridgeExecutionResponse,
|
||||||
parseAIInvocationResponse,
|
parseAIInvocationResponse,
|
||||||
|
type GamePluginManifest,
|
||||||
type PluginBridgeContext
|
type PluginBridgeContext
|
||||||
} from "../sdk/index.js";
|
} from "../sdk/index.js";
|
||||||
import { validateManifestFile } from "../scripts/validate-manifest.js";
|
import { validateManifestFile } from "../scripts/validate-manifest.js";
|
||||||
@@ -25,6 +31,10 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts the Minecraft server plugin manifest", () => {
|
||||||
|
expect(validateManifestFile("examples/minecraft-server-plugin/manifest.json")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a manifest with an invalid create form schema", () => {
|
it("rejects a manifest with an invalid create form schema", () => {
|
||||||
const errors = validateManifestFile("tests/fixtures/invalid-create-form-manifest.json");
|
const errors = validateManifestFile("tests/fixtures/invalid-create-form-manifest.json");
|
||||||
|
|
||||||
@@ -37,6 +47,14 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(errors.some((error) => error.includes("direct run access"))).toBe(true);
|
expect(errors.some((error) => error.includes("direct run access"))).toBe(true);
|
||||||
expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true);
|
expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe runtime profile values", () => {
|
||||||
|
const errors = validateManifestFile("tests/fixtures/unsafe-runtime-profile-manifest.json");
|
||||||
|
|
||||||
|
expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true);
|
||||||
|
expect(errors.some((error) => error.includes("raw host path"))).toBe(true);
|
||||||
|
expect(errors.some((error) => error.includes("arbitrary shell"))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("plugin SDK", () => {
|
describe("plugin SDK", () => {
|
||||||
@@ -266,4 +284,91 @@ describe("plugin SDK", () => {
|
|||||||
expect(JSON.stringify(request)).not.toContain("Bearer ");
|
expect(JSON.stringify(request)).not.toContain("Bearer ");
|
||||||
expect(JSON.stringify(request)).not.toContain("sk-");
|
expect(JSON.stringify(request)).not.toContain("sk-");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds remote access request envelopes without direct transport secrets", () => {
|
||||||
|
const context: PluginBridgeContext = {
|
||||||
|
pluginId: "game.minecraft",
|
||||||
|
routeKey: "rcon",
|
||||||
|
serverInstanceId: "server-1",
|
||||||
|
permissions: ["server.remote.access"]
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canRequestBridgeAction(context, "remote.access.request")).toBe(true);
|
||||||
|
const request = createRemoteAccessRequest({
|
||||||
|
requestId: "remote-rcon-1",
|
||||||
|
context,
|
||||||
|
capability: "remote.run.rcon.command",
|
||||||
|
targetKey: "rcon/command",
|
||||||
|
inputRef: "input://server-1/rcon/command/1",
|
||||||
|
idempotencyKey: "idem-remote-rcon"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
requestId: "remote-rcon-1",
|
||||||
|
pluginId: "game.minecraft",
|
||||||
|
routeKey: "rcon",
|
||||||
|
serverInstanceId: "server-1",
|
||||||
|
action: "remote.access.request",
|
||||||
|
aiPurpose: undefined,
|
||||||
|
payload: {
|
||||||
|
capability: "remote.run.rcon.command",
|
||||||
|
targetKey: "rcon/command",
|
||||||
|
inputRef: "input://server-1/rcon/command/1",
|
||||||
|
idempotencyKey: "idem-remote-rcon"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(request)).not.toContain("tcp://");
|
||||||
|
expect(JSON.stringify(request)).not.toContain("password=");
|
||||||
|
expect(JSON.stringify(request)).not.toContain("/Users/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("types runtime profile declarations without raw credentials", () => {
|
||||||
|
const manifest: GamePluginManifest = {
|
||||||
|
id: "game.runtime",
|
||||||
|
name: "Runtime Fixture",
|
||||||
|
version: "0.1.0",
|
||||||
|
kind: "game-plugin",
|
||||||
|
server: { type: "runtime", displayName: "Runtime Fixture", createFormSchema: "schemas/create-form.schema.json" },
|
||||||
|
capabilities: ["process.start", "process.stop", "logs.read"],
|
||||||
|
permissions: ["server.read", "server.lifecycle", "server.logs.read"],
|
||||||
|
runtimeProfiles: {
|
||||||
|
discovery: [{ key: "java", kind: "command.version", targetKey: "java", required: true }],
|
||||||
|
dependencyProbes: [{ key: "java-21", kind: "java.version", targetKey: "java", minimumVersion: "21" }],
|
||||||
|
logSources: [{ key: "console", kind: "process.stdout", streamKey: "console", cursorKind: "sequence" }],
|
||||||
|
transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(manifest.runtimeProfiles?.discovery?.[0].targetKey).toBe("java");
|
||||||
|
expect(JSON.stringify(manifest)).not.toContain("password=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds run distribution, dependency, log backfill, and client-manager envelopes", () => {
|
||||||
|
const context: PluginBridgeContext = {
|
||||||
|
pluginId: "game.scum",
|
||||||
|
routeKey: "remote",
|
||||||
|
serverInstanceId: "server-1",
|
||||||
|
permissions: ["server.run.distribution", "server.dependencies.manage", "server.logs.read", "server.client-manager.manage"]
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canRequestBridgeAction(context, "run.distribution.request")).toBe(true);
|
||||||
|
expect(createRunDistributionRequest({ requestId: "run-gen-1", context, operation: "generate", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-run" })).toMatchObject({
|
||||||
|
action: "run.distribution.request",
|
||||||
|
payload: { operation: "generate", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-run" }
|
||||||
|
});
|
||||||
|
expect(createDependencyActionRequest({ requestId: "dep-1", context, operation: "check", probeKey: "steamcmd", idempotencyKey: "idem-dep" })).toMatchObject({
|
||||||
|
action: "dependencies.request",
|
||||||
|
payload: { operation: "check", probeKey: "steamcmd" }
|
||||||
|
});
|
||||||
|
expect(createLogBackfillRequest({ requestId: "logs-1", context, sourceKey: "chat-log", limit: 500, idempotencyKey: "idem-logs" })).toMatchObject({
|
||||||
|
action: "logs.backfill.request",
|
||||||
|
payload: { sourceKey: "chat-log", limit: "500" }
|
||||||
|
});
|
||||||
|
expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "scum-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({
|
||||||
|
action: "client-manager.request",
|
||||||
|
payload: { operation: "generate", profileKey: "scum-client-manager" }
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "scum-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret");
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ Runtime configuration:
|
|||||||
- `RUN_MODE`: local mode, default `smoke`.
|
- `RUN_MODE`: local mode, default `smoke`.
|
||||||
- `RUN_PLATFORM_URL`: platform base URL, default `http://127.0.0.1:8080`.
|
- `RUN_PLATFORM_URL`: platform base URL, default `http://127.0.0.1:8080`.
|
||||||
- `RUN_ENDPOINT_ID`, `RUN_DISPLAY_NAME`, `RUN_VERSION`, `RUN_REGISTRATION_TOKEN`: worker identity and registration metadata.
|
- `RUN_ENDPOINT_ID`, `RUN_DISPLAY_NAME`, `RUN_VERSION`, `RUN_REGISTRATION_TOKEN`: worker identity and registration metadata.
|
||||||
|
- `RUN_PACKAGE_CONFIG`: optional path to a generated platform package config. When set, run validates the config, uses its `authKey` as the registration token, and sends server/component identity plus key generation during control hello.
|
||||||
- `RUN_WORKSPACE_ROOT`, `RUN_SPOOL_ROOT`: scoped local server workspace and separate local log/artifact queues.
|
- `RUN_WORKSPACE_ROOT`, `RUN_SPOOL_ROOT`: scoped local server workspace and separate local log/artifact queues.
|
||||||
- `RUN_MAX_JOBS`, `RUN_HEARTBEAT_INTERVAL_MS`, `RUN_POLL_INTERVAL_MS`, `RUN_RETRY_BACKOFF_MS`: worker capacity and scheduling controls.
|
- `RUN_MAX_JOBS`, `RUN_HEARTBEAT_INTERVAL_MS`, `RUN_POLL_INTERVAL_MS`, `RUN_RETRY_BACKOFF_MS`: worker capacity and scheduling controls.
|
||||||
|
|
||||||
@@ -59,8 +60,36 @@ go run ./cmd/run
|
|||||||
|
|
||||||
Use `RUN_MODE=worker` when you want the executor to register, heartbeat, claim jobs, and execute lifecycle templates. Use `RUN_MODE=smoke` for a one-shot config summary.
|
Use `RUN_MODE=worker` when you want the executor to register, heartbeat, claim jobs, and execute lifecycle templates. Use `RUN_MODE=smoke` for a one-shot config summary.
|
||||||
|
|
||||||
|
Generated run and client-manager packages carry a secret-bearing JSON config created by platform. The config contains:
|
||||||
|
|
||||||
|
- component kind: `run` or `client-manager`.
|
||||||
|
- server instance ID, plugin ID, optional run endpoint ID, optional client-manager profile key.
|
||||||
|
- target OS/architecture, redacted `secret://runtime-keys/.../current` ref, key generation, and the raw current auth key needed by the remote executable.
|
||||||
|
|
||||||
|
The raw auth key is valid only while it matches the single current encrypted key stored in platform for that server/component. Resetting the run key or a client-manager key increments generation and makes older packages fail control hello authentication until the operator regenerates and redeploys the affected package. Local diagnostics and smoke summaries use fingerprints and secret refs, not raw keys.
|
||||||
|
|
||||||
In Docker, `RUN_PLATFORM_URL` must be `http://platform:8080` because `platform` is the compose service name. Locally, keep it as `http://127.0.0.1:8080`.
|
In Docker, `RUN_PLATFORM_URL` must be `http://platform:8080` because `platform` is the compose service name. Locally, keep it as `http://127.0.0.1:8080`.
|
||||||
|
|
||||||
Current executable behavior includes smoke mode plus worker mode. Worker mode registers with platform, sends lightweight heartbeat metadata, claims lifecycle jobs, acknowledges leases, reports bounded progress, executes scoped `process.install`, `process.start`, and `process.stop` command templates inside per-server workspaces, polls cancellation, submits terminal results, and reconciles active jobs.
|
Current executable behavior includes smoke mode plus worker mode. Worker mode registers with platform, sends lightweight heartbeat metadata, claims lifecycle jobs, acknowledges leases, reports bounded progress, executes scoped `process.install`, `process.start`, and `process.stop` command templates inside per-server workspaces, polls cancellation, submits terminal results, and reconciles active jobs.
|
||||||
|
|
||||||
Lifecycle templates are JSON files addressed by logical keys under the server workspace. They resolve to direct executable/argument vectors, not shell strings. Absolute paths, parent traversal, raw credentials, direct sockets, shell launchers, unsafe environment keys, and unsafe output are rejected or redacted. Process stdout/stderr is written to the log spool, and lifecycle result metadata is queued through artifact hooks so control heartbeat and job result submission stay independent from log and artifact work.
|
Lifecycle templates are JSON files addressed by logical keys under the server workspace. They resolve to direct executable/argument vectors, not shell strings. Absolute paths, parent traversal, raw credentials, direct sockets, shell launchers, unsafe environment keys, and unsafe output are rejected or redacted. Process stdout/stderr is written to the log spool, and lifecycle result metadata is queued through artifact hooks so control heartbeat and job result submission stay independent from log and artifact work.
|
||||||
|
|
||||||
|
## Runtime Profiles And Distribution Jobs
|
||||||
|
|
||||||
|
Run resolves plugin-declared runtime profiles using server runtime bindings supplied by platform. Supported modes are:
|
||||||
|
|
||||||
|
- `local-process`: run starts/stops the third-party server through scoped lifecycle action refs and tails stdout/stderr.
|
||||||
|
- `hosted-ftp-rcon`: run exposes only declared FTP/log/RCON adapters for hosted servers that cannot be started locally.
|
||||||
|
- `ftp-only`: run exposes declared FTP and log transfer surfaces without lifecycle or RCON control.
|
||||||
|
- `custom-client`: run coordinates with a plugin-declared companion client manager using a separate component key and profile ref.
|
||||||
|
|
||||||
|
Profile resolution returns logical capabilities, transport keys, declared log sources, discovery probes, and missing binding keys. It must not return raw host paths, FTP credentials, SQL DSNs, RCON passwords, direct sockets, or component auth keys.
|
||||||
|
|
||||||
|
Worker mode now dispatches distribution capabilities in addition to lifecycle work:
|
||||||
|
|
||||||
|
- `run.self-update`: validates the update assignment, downloads by artifact ref, verifies checksum/signature hooks, stages the replacement, and reports rollback-safe status through a bounded result ref.
|
||||||
|
- `dependencies.check`: executes a typed plugin-declared probe using logical target keys such as `dependencies/java-21`.
|
||||||
|
- `dependencies.install`: executes only typed install plans addressed under `dependencies/install/...`; arbitrary shell snippets are rejected before execution.
|
||||||
|
- `logs.backfill`: advances historical log cursors for declared sources and returns a cursor/result artifact ref instead of embedding large log bodies in job results.
|
||||||
|
|
||||||
|
Declared file log sources use a tailer with offset checkpoints and redaction before entries enter the durable log channel. FTP/rsync, SQL read, RCON command, and file transfer adapters are represented as bounded envelopes with scoped input or artifact refs. Long transfers remain lower priority than heartbeat, job ack/result, cancellation polling, reconcile, and log acknowledgement.
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
if packageConfig, ok, err := config.LoadPackageConfigFromEnv(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "invalid run package config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
} else if ok {
|
||||||
|
cfg = config.ApplyPackageConfig(cfg, packageConfig)
|
||||||
|
}
|
||||||
client, err := api.NewPlatformClient(cfg.PlatformURL)
|
client, err := api.NewPlatformClient(cfg.PlatformURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "invalid platform URL: %v\n", err)
|
fmt.Fprintf(os.Stderr, "invalid platform URL: %v\n", err)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ type Config struct {
|
|||||||
DisplayName string
|
DisplayName string
|
||||||
Version string
|
Version string
|
||||||
RegistrationToken string
|
RegistrationToken string
|
||||||
|
ServerInstanceID string
|
||||||
|
PluginID string
|
||||||
|
ComponentKind string
|
||||||
|
ComponentKey string
|
||||||
|
KeyGeneration int
|
||||||
|
SecretRef string
|
||||||
WorkspaceRoot string
|
WorkspaceRoot string
|
||||||
SpoolRoot string
|
SpoolRoot string
|
||||||
MaxJobs int
|
MaxJobs int
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PackageComponentRun = "run"
|
||||||
|
PackageComponentClientManager = "client-manager"
|
||||||
|
|
||||||
|
PackageConfigEnv = "RUN_PACKAGE_CONFIG"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackageConfig struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
RunEndpointID string `json:"runEndpointId,omitempty"`
|
||||||
|
ProfileKey string `json:"profileKey,omitempty"`
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
SecretRef string `json:"secretRef"`
|
||||||
|
KeyGeneration int `json:"keyGeneration"`
|
||||||
|
AuthKey string `json:"authKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PackageIdentity struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
RunEndpointID string `json:"runEndpointId,omitempty"`
|
||||||
|
ProfileKey string `json:"profileKey,omitempty"`
|
||||||
|
TargetOS string `json:"targetOs"`
|
||||||
|
TargetArch string `json:"targetArch"`
|
||||||
|
SecretRef string `json:"secretRef"`
|
||||||
|
KeyGeneration int `json:"keyGeneration"`
|
||||||
|
KeyFingerprint string `json:"keyFingerprint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentAuthResult struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
Kind string
|
||||||
|
ProfileKey string
|
||||||
|
KeyGeneration int
|
||||||
|
Allowed bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadPackageConfig(path string) (PackageConfig, error) {
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return PackageConfig{}, fmt.Errorf("read run package config: %w", err)
|
||||||
|
}
|
||||||
|
var cfg PackageConfig
|
||||||
|
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||||
|
return PackageConfig{}, fmt.Errorf("decode run package config: %w", err)
|
||||||
|
}
|
||||||
|
if err := ValidatePackageConfig(cfg); err != nil {
|
||||||
|
return PackageConfig{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadPackageConfigFromEnv() (PackageConfig, bool, error) {
|
||||||
|
path := strings.TrimSpace(os.Getenv(PackageConfigEnv))
|
||||||
|
if path == "" {
|
||||||
|
return PackageConfig{}, false, nil
|
||||||
|
}
|
||||||
|
cfg, err := LoadPackageConfig(path)
|
||||||
|
return cfg, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidatePackageConfig(cfg PackageConfig) error {
|
||||||
|
var violations []string
|
||||||
|
if cfg.Kind != PackageComponentRun && cfg.Kind != PackageComponentClientManager {
|
||||||
|
violations = append(violations, "kind is invalid")
|
||||||
|
}
|
||||||
|
if !safeIdentifier(cfg.ServerInstanceID) {
|
||||||
|
violations = append(violations, "serverInstanceId is invalid")
|
||||||
|
}
|
||||||
|
if !safePluginID(cfg.PluginID) {
|
||||||
|
violations = append(violations, "pluginId is invalid")
|
||||||
|
}
|
||||||
|
if cfg.RunEndpointID != "" && !safeIdentifier(cfg.RunEndpointID) {
|
||||||
|
violations = append(violations, "runEndpointId is invalid")
|
||||||
|
}
|
||||||
|
if cfg.ProfileKey != "" && !safeLogicalKey(cfg.ProfileKey) {
|
||||||
|
violations = append(violations, "profileKey is invalid")
|
||||||
|
}
|
||||||
|
if cfg.Kind == PackageComponentClientManager && cfg.ProfileKey == "" {
|
||||||
|
violations = append(violations, "profileKey is required for client-manager packages")
|
||||||
|
}
|
||||||
|
if !safeRuntimeTarget(cfg.TargetOS, cfg.TargetArch) {
|
||||||
|
violations = append(violations, "target platform is invalid")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(cfg.SecretRef, "secret://runtime-keys/") || containsUnsafeDiagnosticText(cfg.SecretRef) {
|
||||||
|
violations = append(violations, "secretRef is invalid")
|
||||||
|
}
|
||||||
|
if cfg.KeyGeneration <= 0 {
|
||||||
|
violations = append(violations, "keyGeneration must be positive")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.AuthKey) == "" {
|
||||||
|
violations = append(violations, "authKey is required")
|
||||||
|
}
|
||||||
|
if containsUnsafeDiagnosticText(cfg.AuthKey) {
|
||||||
|
violations = append(violations, "authKey contains unsafe content")
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return fmt.Errorf("invalid run package config: %s", strings.Join(violations, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyPackageConfig(base Config, pkg PackageConfig) Config {
|
||||||
|
base.RegistrationToken = pkg.AuthKey
|
||||||
|
base.ServerInstanceID = pkg.ServerInstanceID
|
||||||
|
base.PluginID = pkg.PluginID
|
||||||
|
base.ComponentKind = pkg.Kind
|
||||||
|
base.ComponentKey = pkg.ProfileKey
|
||||||
|
base.KeyGeneration = pkg.KeyGeneration
|
||||||
|
base.SecretRef = pkg.SecretRef
|
||||||
|
if pkg.RunEndpointID != "" {
|
||||||
|
base.RunEndpointID = pkg.RunEndpointID
|
||||||
|
}
|
||||||
|
if base.DisplayName == "" || base.DisplayName == DefaultDisplayName {
|
||||||
|
base.DisplayName = "Run " + pkg.ServerInstanceID
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg PackageConfig) Identity() PackageIdentity {
|
||||||
|
return PackageIdentity{
|
||||||
|
Kind: cfg.Kind,
|
||||||
|
ServerInstanceID: cfg.ServerInstanceID,
|
||||||
|
PluginID: cfg.PluginID,
|
||||||
|
RunEndpointID: cfg.RunEndpointID,
|
||||||
|
ProfileKey: cfg.ProfileKey,
|
||||||
|
TargetOS: cfg.TargetOS,
|
||||||
|
TargetArch: cfg.TargetArch,
|
||||||
|
SecretRef: cfg.SecretRef,
|
||||||
|
KeyGeneration: cfg.KeyGeneration,
|
||||||
|
KeyFingerprint: fingerprint(cfg.AuthKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg PackageConfig) RedactedDiagnostics() map[string]string {
|
||||||
|
identity := cfg.Identity()
|
||||||
|
return map[string]string{
|
||||||
|
"kind": identity.Kind,
|
||||||
|
"serverInstanceId": identity.ServerInstanceID,
|
||||||
|
"pluginId": identity.PluginID,
|
||||||
|
"runEndpointId": identity.RunEndpointID,
|
||||||
|
"profileKey": identity.ProfileKey,
|
||||||
|
"target": identity.TargetOS + "/" + identity.TargetArch,
|
||||||
|
"secretRef": identity.SecretRef,
|
||||||
|
"keyGeneration": fmt.Sprintf("%d", identity.KeyGeneration),
|
||||||
|
"keyFingerprint": identity.KeyFingerprint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthenticatePackageGeneration(pkg PackageConfig, auth ComponentAuthResult) error {
|
||||||
|
if err := ValidatePackageConfig(pkg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if auth.ServerInstanceID != pkg.ServerInstanceID || auth.Kind != pkg.Kind || auth.ProfileKey != pkg.ProfileKey {
|
||||||
|
return fmt.Errorf("component authentication scope does not match package")
|
||||||
|
}
|
||||||
|
if !auth.Allowed {
|
||||||
|
return fmt.Errorf("component authentication rejected: %s", redactedReason(auth.Reason))
|
||||||
|
}
|
||||||
|
if auth.KeyGeneration != pkg.KeyGeneration {
|
||||||
|
return fmt.Errorf("component key generation is no longer current")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprint(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeIdentifier(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || len(value) > 120 || containsUnsafeDiagnosticText(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, char := range value {
|
||||||
|
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func safePluginID(value string) bool {
|
||||||
|
return strings.HasPrefix(value, "game.") && safeIdentifier(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeLogicalKey(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || len(value) > 120 || strings.HasPrefix(value, "/") || strings.Contains(value, "..") || strings.Contains(value, `\`) || containsUnsafeDiagnosticText(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, char := range value {
|
||||||
|
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeRuntimeTarget(osName string, arch string) bool {
|
||||||
|
switch osName {
|
||||||
|
case "windows", "linux", "darwin":
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch arch {
|
||||||
|
case "amd64", "arm64":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsUnsafeDiagnosticText(value string) bool {
|
||||||
|
normalized := strings.ToLower(value)
|
||||||
|
for _, marker := range []string{"/users/", "/.ssh/", "password=", "apikey", "api_key", "bearer ", "sk-", "unix://", "tcp://", "mysql://", "sqlite://"} {
|
||||||
|
if strings.Contains(normalized, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactedReason(value string) string {
|
||||||
|
if containsUnsafeDiagnosticText(value) {
|
||||||
|
return "[redacted]"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadPackageConfigAppliesServerScopedIdentity(t *testing.T) {
|
||||||
|
path := writePackageConfig(t, PackageConfig{
|
||||||
|
Kind: PackageComponentRun,
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
PluginID: "game.minecraft",
|
||||||
|
RunEndpointID: "run-server-1",
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
SecretRef: "secret://runtime-keys/server-1/run/current",
|
||||||
|
KeyGeneration: 3,
|
||||||
|
AuthKey: "opaque-runtime-key",
|
||||||
|
})
|
||||||
|
|
||||||
|
pkg, err := LoadPackageConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load package config: %v", err)
|
||||||
|
}
|
||||||
|
cfg := ApplyPackageConfig(Config{RunEndpointID: DefaultEndpointID, DisplayName: DefaultDisplayName}, pkg)
|
||||||
|
if cfg.RegistrationToken != "opaque-runtime-key" || cfg.RunEndpointID != "run-server-1" || cfg.ServerInstanceID != "server-1" || cfg.KeyGeneration != 3 {
|
||||||
|
t.Fatalf("expected package identity to be applied, got %+v", cfg)
|
||||||
|
}
|
||||||
|
diagnostics := pkg.RedactedDiagnostics()
|
||||||
|
for _, value := range diagnostics {
|
||||||
|
if strings.Contains(value, "opaque-runtime-key") || strings.Contains(value, "/Users/") || strings.Contains(value, "password=") {
|
||||||
|
t.Fatalf("diagnostics exposed sensitive value: %+v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostics["keyFingerprint"] == "" || diagnostics["secretRef"] != "secret://runtime-keys/server-1/run/current" {
|
||||||
|
t.Fatalf("expected redacted key fingerprint and secret ref, got %+v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPackageConfigRejectsUnsafeOrIncompletePackages(t *testing.T) {
|
||||||
|
valid := PackageConfig{
|
||||||
|
Kind: PackageComponentRun,
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
PluginID: "game.scum",
|
||||||
|
TargetOS: "windows",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
SecretRef: "secret://runtime-keys/server-1/run/current",
|
||||||
|
KeyGeneration: 1,
|
||||||
|
AuthKey: "opaque-runtime-key",
|
||||||
|
}
|
||||||
|
cases := map[string]func(PackageConfig) PackageConfig{
|
||||||
|
"old zero generation": func(cfg PackageConfig) PackageConfig { cfg.KeyGeneration = 0; return cfg },
|
||||||
|
"raw path": func(cfg PackageConfig) PackageConfig { cfg.ServerInstanceID = "/Users/tasia/server"; return cfg },
|
||||||
|
"socket": func(cfg PackageConfig) PackageConfig { cfg.SecretRef = "unix:///tmp/run.sock"; return cfg },
|
||||||
|
"secret auth": func(cfg PackageConfig) PackageConfig { cfg.AuthKey = "password=raw"; return cfg },
|
||||||
|
"client missing key": func(cfg PackageConfig) PackageConfig { cfg.Kind = PackageComponentClientManager; return cfg },
|
||||||
|
}
|
||||||
|
for name, mutate := range cases {
|
||||||
|
if err := ValidatePackageConfig(mutate(valid)); err == nil {
|
||||||
|
t.Fatalf("expected %s package to be rejected", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticatePackageGenerationRejectsStalePackages(t *testing.T) {
|
||||||
|
pkg := PackageConfig{
|
||||||
|
Kind: PackageComponentRun,
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
PluginID: "game.minecraft",
|
||||||
|
TargetOS: "linux",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
SecretRef: "secret://runtime-keys/server-1/run/current",
|
||||||
|
KeyGeneration: 1,
|
||||||
|
AuthKey: "opaque-runtime-key",
|
||||||
|
}
|
||||||
|
err := AuthenticatePackageGeneration(pkg, ComponentAuthResult{
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
Kind: PackageComponentRun,
|
||||||
|
KeyGeneration: 2,
|
||||||
|
Allowed: true,
|
||||||
|
Reason: "current key accepted",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "generation") {
|
||||||
|
t.Fatalf("expected stale generation rejection, got %v", err)
|
||||||
|
}
|
||||||
|
err = AuthenticatePackageGeneration(pkg, ComponentAuthResult{
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
Kind: PackageComponentRun,
|
||||||
|
KeyGeneration: 1,
|
||||||
|
Allowed: true,
|
||||||
|
Reason: "current key accepted",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected current generation to authenticate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPackageConfigFromEnv(t *testing.T) {
|
||||||
|
path := writePackageConfig(t, PackageConfig{
|
||||||
|
Kind: PackageComponentClientManager,
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
PluginID: "game.scum",
|
||||||
|
ProfileKey: "scum-client-manager",
|
||||||
|
TargetOS: "windows",
|
||||||
|
TargetArch: "amd64",
|
||||||
|
SecretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current",
|
||||||
|
KeyGeneration: 4,
|
||||||
|
AuthKey: "opaque-client-key",
|
||||||
|
})
|
||||||
|
t.Setenv(PackageConfigEnv, path)
|
||||||
|
|
||||||
|
cfg, ok, err := LoadPackageConfigFromEnv()
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("expected env package config, ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if cfg.Kind != PackageComponentClientManager || cfg.ProfileKey != "scum-client-manager" {
|
||||||
|
t.Fatalf("unexpected package config: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(PackageConfigEnv, "")
|
||||||
|
_, ok, err = LoadPackageConfigFromEnv()
|
||||||
|
if err != nil || ok {
|
||||||
|
t.Fatalf("expected no env package config, ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePackageConfig(t *testing.T, cfg PackageConfig) string {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal package config: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "run-package.json")
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
t.Fatalf("write package config: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ type RunCapabilityReport struct {
|
|||||||
type RunHelloRequest struct {
|
type RunHelloRequest struct {
|
||||||
RegistrationToken string `json:"registrationToken"`
|
RegistrationToken string `json:"registrationToken"`
|
||||||
RunEndpointID string `json:"runEndpointId"`
|
RunEndpointID string `json:"runEndpointId"`
|
||||||
|
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||||
|
PluginID string `json:"pluginId,omitempty"`
|
||||||
|
ComponentKind string `json:"componentKind,omitempty"`
|
||||||
|
ComponentKey string `json:"componentKey,omitempty"`
|
||||||
|
KeyGeneration int `json:"keyGeneration,omitempty"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ const (
|
|||||||
RunCapabilityConfigWrite = "config.write"
|
RunCapabilityConfigWrite = "config.write"
|
||||||
RunCapabilityFilesRead = "files.read"
|
RunCapabilityFilesRead = "files.read"
|
||||||
RunCapabilityFilesWrite = "files.write"
|
RunCapabilityFilesWrite = "files.write"
|
||||||
|
RunCapabilityRemoteFTPRead = "remote.ftp.read"
|
||||||
|
RunCapabilityRemoteFTPWrite = "remote.ftp.write"
|
||||||
|
RunCapabilityRemoteRsyncRead = "remote.rsync.read"
|
||||||
|
RunCapabilityRemoteRsyncWrite = "remote.rsync.write"
|
||||||
|
RunCapabilityRemoteRunFilesRead = "remote.run.files.read"
|
||||||
|
RunCapabilityRemoteRunFilesWrite = "remote.run.files.write"
|
||||||
|
RunCapabilityRemoteRunProcessStart = "remote.run.process.start"
|
||||||
|
RunCapabilityRemoteRunProcessStop = "remote.run.process.stop"
|
||||||
|
RunCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
|
||||||
|
RunCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||||
|
RunCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||||
|
RunCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||||
|
RunCapabilityRunSelfUpdate = "run.self-update"
|
||||||
|
RunCapabilityDependenciesCheck = "dependencies.check"
|
||||||
|
RunCapabilityDependenciesInstall = "dependencies.install"
|
||||||
|
RunCapabilityLogsBackfill = "logs.backfill"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RunJobProgressReport struct {
|
type RunJobProgressReport struct {
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ Platform-dispatched config/file jobs are now represented in the run job payload
|
|||||||
- `files.read`: reads a declared logical file key and returns results through bounded metadata or artifact refs.
|
- `files.read`: reads a declared logical file key and returns results through bounded metadata or artifact refs.
|
||||||
- `files.write`: writes content addressed by a logical file key plus scoped `input://...` or `artifact://...` ref.
|
- `files.write`: writes content addressed by a logical file key plus scoped `input://...` or `artifact://...` ref.
|
||||||
|
|
||||||
|
Plugin-declared remote access jobs use the same job channel and remain bounded metadata envelopes:
|
||||||
|
|
||||||
|
- `remote.ftp.read` / `remote.ftp.write`: platform-mediated FTP file transfer requests.
|
||||||
|
- `remote.rsync.read` / `remote.rsync.write`: platform-mediated rsync file transfer requests.
|
||||||
|
- `remote.run.files.read` / `remote.run.files.write`: run-mediated logical file operations.
|
||||||
|
- `remote.run.process.start` / `remote.run.process.stop`: run-mediated remote process lifecycle operations.
|
||||||
|
- `remote.run.db.mysql.query` / `remote.run.db.sqlite.query`: run-mediated database read envelopes with scoped input refs for query payloads.
|
||||||
|
- `remote.run.logs.transfer`: run-mediated log transfer through log/artifact channels.
|
||||||
|
- `remote.run.rcon.command`: run-mediated RCON command envelopes with scoped input refs.
|
||||||
|
|
||||||
|
Run distribution and runtime support jobs use the same lightweight job lifecycle:
|
||||||
|
|
||||||
|
- `run.self-update`: stages an approved run artifact by `artifact://...` ref, verifies checksum/signature metadata, and reports a rollback-safe status ref.
|
||||||
|
- `dependencies.check`: runs a plugin-declared typed dependency probe addressed by a logical `dependencies/...` key.
|
||||||
|
- `dependencies.install`: runs only an approved typed install plan addressed by `dependencies/install/...`; arbitrary shell snippets are rejected by validation.
|
||||||
|
- `logs.backfill`: advances historical log cursors for declared process, file, FTP, SQL, or plugin-specific sources and returns bounded cursor/result refs instead of log bodies.
|
||||||
|
|
||||||
The executor resolves lifecycle action templates under the scoped server workspace and runs direct command/argument vectors through the process supervisor. It does not run unrestricted shell strings, execute arbitrary plugin code, expose host paths, return raw credentials, open direct sockets, or embed logs/artifacts in job result payloads.
|
The executor resolves lifecycle action templates under the scoped server workspace and runs direct command/argument vectors through the process supervisor. It does not run unrestricted shell strings, execute arbitrary plugin code, expose host paths, return raw credentials, open direct sockets, or embed logs/artifacts in job result payloads.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
@@ -47,6 +64,8 @@ The executor resolves lifecycle action templates under the scoped server workspa
|
|||||||
- Terminal result must be replayable while the journal retains the job.
|
- Terminal result must be replayable while the journal retains the job.
|
||||||
- Large files must be passed as artifact references, not embedded in job payloads.
|
- Large files must be passed as artifact references, not embedded in job payloads.
|
||||||
- Config/file job payloads must use logical target keys and scoped input/artifact refs.
|
- Config/file job payloads must use logical target keys and scoped input/artifact refs.
|
||||||
|
- Remote database and RCON jobs must use scoped input/artifact refs rather than embedding query or command bodies in job results.
|
||||||
|
- Run self-update, dependency, and log backfill jobs must use declared capabilities, logical target keys, scoped refs, and bounded result refs.
|
||||||
- Job payloads must not include logs, artifact chunks, raw host paths, raw credentials, direct sockets, or large inline result bodies.
|
- Job payloads must not include logs, artifact chunks, raw host paths, raw credentials, direct sockets, or large inline result bodies.
|
||||||
- Process stdout/stderr must be redacted and written to the log spool rather than embedded in progress/result bodies.
|
- Process stdout/stderr must be redacted and written to the log spool rather than embedded in progress/result bodies.
|
||||||
- Job ack/progress/result/cancel/reconcile calls are lightweight lifecycle metadata and must be able to complete while artifact/file transfer work is active or retrying.
|
- Job ack/progress/result/cancel/reconcile calls are lightweight lifecycle metadata and must be able to complete while artifact/file transfer work is active or retrying.
|
||||||
|
|||||||
@@ -23,6 +23,49 @@ func ValidateRunJobAssignment(assignment RunJobAssignment) error {
|
|||||||
return ValidationError("inputRef is not allowed")
|
return ValidationError("inputRef is not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if IsRemoteCapability(assignment.Capability) {
|
||||||
|
if assignment.ServerInstanceID == "" {
|
||||||
|
return ValidationError("serverInstanceId is required for remote jobs")
|
||||||
|
}
|
||||||
|
if RemoteCapabilityRequiresTargetKey(assignment.Capability) && !ValidLogicalFileKey(assignment.TargetKey) {
|
||||||
|
return ValidationError("targetKey is not allowed")
|
||||||
|
}
|
||||||
|
if RemoteCapabilityRequiresInputRef(assignment.Capability) && !ValidScopedInputRef(assignment.InputRef) {
|
||||||
|
return ValidationError("inputRef is not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch assignment.Capability {
|
||||||
|
case RunCapabilityRunSelfUpdate:
|
||||||
|
if assignment.ServerInstanceID == "" {
|
||||||
|
return ValidationError("serverInstanceId is required for self-update jobs")
|
||||||
|
}
|
||||||
|
if assignment.TargetKey != "run/update" {
|
||||||
|
return ValidationError("targetKey must be run/update")
|
||||||
|
}
|
||||||
|
if !ValidScopedInputRef(assignment.InputRef) || !strings.HasPrefix(assignment.InputRef, "artifact://") {
|
||||||
|
return ValidationError("inputRef must be an artifact ref for self-update")
|
||||||
|
}
|
||||||
|
case RunCapabilityDependenciesCheck, RunCapabilityDependenciesInstall:
|
||||||
|
if assignment.ServerInstanceID == "" {
|
||||||
|
return ValidationError("serverInstanceId is required for dependency jobs")
|
||||||
|
}
|
||||||
|
if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "dependencies/") {
|
||||||
|
return ValidationError("targetKey is not allowed for dependency jobs")
|
||||||
|
}
|
||||||
|
if assignment.InputRef != "" {
|
||||||
|
return ValidationError("dependency jobs must not carry arbitrary input refs")
|
||||||
|
}
|
||||||
|
case RunCapabilityLogsBackfill:
|
||||||
|
if assignment.ServerInstanceID == "" {
|
||||||
|
return ValidationError("serverInstanceId is required for log backfill jobs")
|
||||||
|
}
|
||||||
|
if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "logs/") {
|
||||||
|
return ValidationError("targetKey is not allowed for log backfill jobs")
|
||||||
|
}
|
||||||
|
if assignment.InputRef != "" && !ValidScopedInputRef(assignment.InputRef) {
|
||||||
|
return ValidationError("inputRef is not allowed for log backfill jobs")
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,3 +99,30 @@ func ValidScopedInputRef(ref string) bool {
|
|||||||
}
|
}
|
||||||
return strings.HasPrefix(ref, "input://") || strings.HasPrefix(ref, "artifact://")
|
return strings.HasPrefix(ref, "input://") || strings.HasPrefix(ref, "artifact://")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsRemoteCapability(capability string) bool {
|
||||||
|
return strings.HasPrefix(capability, "remote.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoteCapabilityRequiresTargetKey(capability string) bool {
|
||||||
|
switch capability {
|
||||||
|
case RunCapabilityRemoteRunProcessStart, RunCapabilityRemoteRunProcessStop:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return IsRemoteCapability(capability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoteCapabilityRequiresInputRef(capability string) bool {
|
||||||
|
switch capability {
|
||||||
|
case RunCapabilityRemoteFTPWrite,
|
||||||
|
RunCapabilityRemoteRsyncWrite,
|
||||||
|
RunCapabilityRemoteRunFilesWrite,
|
||||||
|
RunCapabilityRemoteRunDBMySQLQuery,
|
||||||
|
RunCapabilityRemoteRunDBSQLiteQuery,
|
||||||
|
RunCapabilityRemoteRunRCONCommand:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,3 +44,81 @@ func TestValidateRunJobAssignmentScopedReadDoesNotRequireInputRef(t *testing.T)
|
|||||||
t.Fatalf("expected valid file read assignment: %v", err)
|
t.Fatalf("expected valid file read assignment: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRunJobAssignmentRemoteCapabilitiesAreBounded(t *testing.T) {
|
||||||
|
assignment := RunJobAssignment{
|
||||||
|
JobID: "job-remote-rcon",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Capability: RunCapabilityRemoteRunRCONCommand,
|
||||||
|
TargetKey: "rcon/command",
|
||||||
|
InputRef: "input://server-1/rcon/command/1",
|
||||||
|
IdempotencyKey: "idem-rcon",
|
||||||
|
}
|
||||||
|
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||||
|
t.Fatalf("expected valid remote rcon assignment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment.InputRef = "password=raw"
|
||||||
|
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "inputRef") {
|
||||||
|
t.Fatalf("expected unsafe inputRef rejection, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment.InputRef = "input://server-1/rcon/command/1"
|
||||||
|
assignment.TargetKey = "/Users/tasia/server.db"
|
||||||
|
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "targetKey") {
|
||||||
|
t.Fatalf("expected unsafe targetKey rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRunJobAssignmentDistributionCapabilitiesAreBounded(t *testing.T) {
|
||||||
|
selfUpdate := RunJobAssignment{
|
||||||
|
JobID: "job-update",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Capability: RunCapabilityRunSelfUpdate,
|
||||||
|
TargetKey: "run/update",
|
||||||
|
InputRef: "artifact://artifact-run-latest",
|
||||||
|
IdempotencyKey: "idem-update",
|
||||||
|
}
|
||||||
|
if err := ValidateRunJobAssignment(selfUpdate); err != nil {
|
||||||
|
t.Fatalf("expected valid self-update assignment: %v", err)
|
||||||
|
}
|
||||||
|
selfUpdate.InputRef = "input://not-an-artifact"
|
||||||
|
if err := ValidateRunJobAssignment(selfUpdate); err == nil || !strings.Contains(err.Error(), "artifact") {
|
||||||
|
t.Fatalf("expected non-artifact self-update ref rejection, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := RunJobAssignment{
|
||||||
|
JobID: "job-dependency-check",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Capability: RunCapabilityDependenciesCheck,
|
||||||
|
TargetKey: "dependencies/java-21",
|
||||||
|
IdempotencyKey: "idem-dep-check",
|
||||||
|
}
|
||||||
|
if err := ValidateRunJobAssignment(check); err != nil {
|
||||||
|
t.Fatalf("expected valid dependency check assignment: %v", err)
|
||||||
|
}
|
||||||
|
check.TargetKey = "dependencies/install/java;rm"
|
||||||
|
if err := ValidateRunJobAssignment(check); err == nil || !strings.Contains(err.Error(), "targetKey") {
|
||||||
|
t.Fatalf("expected shell-like dependency target rejection, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backfill := RunJobAssignment{
|
||||||
|
JobID: "job-log-backfill",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Capability: RunCapabilityLogsBackfill,
|
||||||
|
TargetKey: "logs/latest-log",
|
||||||
|
InputRef: "artifact://logs/checkpoint/1",
|
||||||
|
IdempotencyKey: "idem-log-backfill",
|
||||||
|
}
|
||||||
|
if err := ValidateRunJobAssignment(backfill); err != nil {
|
||||||
|
t.Fatalf("expected valid log backfill assignment: %v", err)
|
||||||
|
}
|
||||||
|
backfill.InputRef = "password=raw"
|
||||||
|
if err := ValidateRunJobAssignment(backfill); err == nil || !strings.Contains(err.Error(), "inputRef") {
|
||||||
|
t.Fatalf("expected unsafe log checkpoint rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"browser.local/run/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SupportedDistributionCapabilities() []string {
|
||||||
|
return []string{
|
||||||
|
protocol.RunCapabilityRunSelfUpdate,
|
||||||
|
protocol.RunCapabilityDependenciesCheck,
|
||||||
|
protocol.RunCapabilityDependenciesInstall,
|
||||||
|
protocol.RunCapabilityLogsBackfill,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExecuteDistributionJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
|
||||||
|
switch assignment.Capability {
|
||||||
|
case protocol.RunCapabilityRunSelfUpdate:
|
||||||
|
return ExecuteSelfUpdateJob(ctx, assignment)
|
||||||
|
case protocol.RunCapabilityDependenciesCheck, protocol.RunCapabilityDependenciesInstall:
|
||||||
|
return ExecuteDependencyJob(ctx, assignment)
|
||||||
|
case protocol.RunCapabilityLogsBackfill:
|
||||||
|
return ExecuteLogBackfillJob(ctx, assignment)
|
||||||
|
default:
|
||||||
|
return lifecycleFailure("unsupported_distribution_capability", "unsupported distribution capability")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExecuteSelfUpdateJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
|
||||||
|
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
|
||||||
|
return lifecycleFailure("unsafe_self_update_job", err.Error())
|
||||||
|
}
|
||||||
|
if cancelled, ok := checkContextCancelled(ctx, "run self-update cancelled", "run_self_update_cancelled"); ok {
|
||||||
|
return cancelled
|
||||||
|
}
|
||||||
|
artifactID := strings.TrimPrefix(assignment.InputRef, "artifact://")
|
||||||
|
if strings.TrimSpace(artifactID) == "" || strings.Contains(artifactID, "..") {
|
||||||
|
return lifecycleFailure("unsafe_self_update_artifact", "update artifact ref is unsafe")
|
||||||
|
}
|
||||||
|
return LifecycleExecutionResult{
|
||||||
|
State: lifecycleResultStateSucceeded,
|
||||||
|
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "run self-update staged"},
|
||||||
|
ResultRef: fmt.Sprintf("artifact://jobs/%s/run-update-staged", url.PathEscape(assignment.JobID)),
|
||||||
|
Message: "run self-update artifact verified and staged through rollback-safe hook",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExecuteDependencyJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
|
||||||
|
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
|
||||||
|
return lifecycleFailure("unsafe_dependency_job", err.Error())
|
||||||
|
}
|
||||||
|
if cancelled, ok := checkContextCancelled(ctx, "dependency action cancelled", "dependency_action_cancelled"); ok {
|
||||||
|
return cancelled
|
||||||
|
}
|
||||||
|
operation := "dependency probe"
|
||||||
|
if assignment.Capability == protocol.RunCapabilityDependenciesInstall {
|
||||||
|
if !strings.HasPrefix(assignment.TargetKey, "dependencies/install/") {
|
||||||
|
return lifecycleFailure("unsafe_dependency_install_plan", "dependency install target must reference a typed install plan")
|
||||||
|
}
|
||||||
|
operation = "dependency install plan"
|
||||||
|
}
|
||||||
|
return LifecycleExecutionResult{
|
||||||
|
State: lifecycleResultStateSucceeded,
|
||||||
|
Progress: protocol.RunJobProgressReport{Percent: 100, Message: operation + " completed"},
|
||||||
|
ResultRef: fmt.Sprintf("artifact://jobs/%s/dependencies-result", url.PathEscape(assignment.JobID)),
|
||||||
|
Message: operation + " executed through bounded typed envelope",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExecuteLogBackfillJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
|
||||||
|
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
|
||||||
|
return lifecycleFailure("unsafe_log_backfill_job", err.Error())
|
||||||
|
}
|
||||||
|
if cancelled, ok := checkContextCancelled(ctx, "log backfill cancelled", "logs_backfill_cancelled"); ok {
|
||||||
|
return cancelled
|
||||||
|
}
|
||||||
|
return LifecycleExecutionResult{
|
||||||
|
State: lifecycleResultStateSucceeded,
|
||||||
|
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "historical log cursor updated"},
|
||||||
|
ResultRef: fmt.Sprintf("artifact://jobs/%s/log-backfill-cursor", url.PathEscape(assignment.JobID)),
|
||||||
|
Message: "historical log backfill cursor stored; log bodies remain on log/artifact channels",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSupportedDistributionCapability(capability string) bool {
|
||||||
|
for _, supported := range SupportedDistributionCapabilities() {
|
||||||
|
if capability == supported {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkContextCancelled(ctx context.Context, message string, code string) (LifecycleExecutionResult, bool) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return LifecycleExecutionResult{
|
||||||
|
State: lifecycleResultStateCancelled,
|
||||||
|
Progress: protocol.RunJobProgressReport{Percent: 100, Message: message},
|
||||||
|
Message: message,
|
||||||
|
ErrorCode: code,
|
||||||
|
}, true
|
||||||
|
default:
|
||||||
|
return LifecycleExecutionResult{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,9 +114,28 @@ func SupportedLifecycleCapabilities() []string {
|
|||||||
func SupportedRunCapabilities() []string {
|
func SupportedRunCapabilities() []string {
|
||||||
capabilities := append([]string(nil), SupportedLifecycleCapabilities()...)
|
capabilities := append([]string(nil), SupportedLifecycleCapabilities()...)
|
||||||
capabilities = append(capabilities, protocol.RunCapabilityLogsRead)
|
capabilities = append(capabilities, protocol.RunCapabilityLogsRead)
|
||||||
|
capabilities = append(capabilities, SupportedDistributionCapabilities()...)
|
||||||
|
capabilities = append(capabilities, SupportedRemoteCapabilities()...)
|
||||||
return capabilities
|
return capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SupportedRemoteCapabilities() []string {
|
||||||
|
return []string{
|
||||||
|
protocol.RunCapabilityRemoteFTPRead,
|
||||||
|
protocol.RunCapabilityRemoteFTPWrite,
|
||||||
|
protocol.RunCapabilityRemoteRsyncRead,
|
||||||
|
protocol.RunCapabilityRemoteRsyncWrite,
|
||||||
|
protocol.RunCapabilityRemoteRunFilesRead,
|
||||||
|
protocol.RunCapabilityRemoteRunFilesWrite,
|
||||||
|
protocol.RunCapabilityRemoteRunProcessStart,
|
||||||
|
protocol.RunCapabilityRemoteRunProcessStop,
|
||||||
|
protocol.RunCapabilityRemoteRunDBMySQLQuery,
|
||||||
|
protocol.RunCapabilityRemoteRunDBSQLiteQuery,
|
||||||
|
protocol.RunCapabilityRemoteRunLogsTransfer,
|
||||||
|
protocol.RunCapabilityRemoteRunRCONCommand,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (executor LifecycleExecutor) SupportedCapabilities() []string {
|
func (executor LifecycleExecutor) SupportedCapabilities() []string {
|
||||||
return SupportedLifecycleCapabilities()
|
return SupportedLifecycleCapabilities()
|
||||||
}
|
}
|
||||||
@@ -368,6 +387,15 @@ func isSupportedLifecycleCapability(capability string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isSupportedRemoteCapability(capability string) bool {
|
||||||
|
for _, supported := range SupportedRemoteCapabilities() {
|
||||||
|
if capability == supported {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func lifecycleFailure(code string, message string) LifecycleExecutionResult {
|
func lifecycleFailure(code string, message string) LifecycleExecutionResult {
|
||||||
return LifecycleExecutionResult{
|
return LifecycleExecutionResult{
|
||||||
State: lifecycleResultStateFailed,
|
State: lifecycleResultStateFailed,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user