feat(plugin): add SCUM ownership migration foundation
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
# SCUM transitional ownership audit
|
||||||
|
|
||||||
|
This audit records the five transitional deliveries that placed SCUM behavior in
|
||||||
|
the platform. They are migration input only; new writes must use the declared
|
||||||
|
plugin bundle and Companion channel.
|
||||||
|
|
||||||
|
| Transitional platform area | Existing ownership | Plugin-owned replacement | Migration dependency |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `api/game_player_handlers.go`, `service/game_players.go`, `domain/game_players.go` | SCUM player profiles, sessions, risk projections | `features/players` page data projected from declared `scum.login`/`scum.logout` semantic events | Companion parser and event uploader |
|
||||||
|
| `api/game_map_trajectory_handlers.go`, `service/game_map_trajectories.go`, `domain/game_map_trajectories.go` | SCUM map conversion and trajectory projection | `features/trajectories` catalog and page projection | A verified server-side source; otherwise the page remains unavailable |
|
||||||
|
| `api/game_gift_handlers.go`, `service/game_gifts.go`, `domain/game_gifts.go` | SCUM catalog, frozen revisions and grant workflow | `features/rewards` contracts plus `reward.deliver` and `player.notify` handlers | Compatible Companion reward handler and approved revision |
|
||||||
|
| `api/game_player_handlers.go`, `service/game_player_state_patch.go`, `domain/game_player_state_patch.go` | SCUM field catalog and state-patch approval | `features/state-patches` versioned field catalog and `game-state.patch` handler | Version discovery and verified safe window |
|
||||||
|
| `components/ScumFileConfigWorkbench.tsx` | SCUM configuration workbench | SCUM page bundle configuration catalog | Companion `config.read`/`config.patch` availability |
|
||||||
|
| `components/GamePlayerIntelligencePanel.tsx`, `GameGiftCatalogPanel.tsx`, `ScumMapTrajectoryPanel.tsx` | SCUM panels imported by the host | SCUM page bundle module | Generic manifest bundle validation and plugin-page host |
|
||||||
|
| `contracts/scumOperations.ts`, `schemas/scumOperations.ts` | `game.scum` host branch | manifest-driven bundle contract | Generic page-bundle registry |
|
||||||
|
|
||||||
|
The generic platform primitives retained by this change are server-scoped
|
||||||
|
authorization, manifest validation, bundle identity/version validation, typed
|
||||||
|
game-client command delivery, idempotent command completion, snapshot/event
|
||||||
|
retention, audit linkage, and unavailable-feature responses. No transitional
|
||||||
|
record is treated as proof that an executable SCUM capability is available.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
## 1. Establish generic extension primitives
|
## 1. Establish generic extension primitives
|
||||||
|
|
||||||
- [ ] 1.1 Audit every SCUM-named platform API, model, service, route, and hard-coded frontend import introduced by the five transitional deliveries; document its plugin-owned replacement and migration dependency.
|
- [x] 1.1 Audit every SCUM-named platform API, model, service, route, and hard-coded frontend import introduced by the five transitional deliveries; document its plugin-owned replacement and migration dependency.
|
||||||
- [ ] 1.2 Define and test generic plugin-scoped record/event storage, audit linkage, retention, and typed command-result primitives without SCUM field names.
|
- [ ] 1.2 Define and test generic plugin-scoped record/event storage, audit linkage, retention, and typed command-result primitives without SCUM field names.
|
||||||
- [ ] 1.3 Extend the plugin manifest/SDK with versioned page-bundle entries, feature capability declarations, and Companion handler/event-producer availability reporting.
|
- [ ] 1.3 Extend the plugin manifest/SDK with versioned page-bundle entries, feature capability declarations, and Companion handler/event-producer availability reporting.
|
||||||
- [ ] 1.4 Add generic platform authorization, server isolation, bundle integrity/version validation, and unavailable-feature behavior for those declarations.
|
- [ ] 1.4 Add generic platform authorization, server isolation, bundle integrity/version validation, and unavailable-feature behavior for those declarations.
|
||||||
|
|||||||
@@ -312,10 +312,20 @@ type GamePluginPage struct {
|
|||||||
Key string
|
Key string
|
||||||
Title string
|
Title string
|
||||||
Path string
|
Path string
|
||||||
|
Bundle PluginPageBundle
|
||||||
Permissions []string
|
Permissions []string
|
||||||
BridgeActions []string
|
BridgeActions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PluginPageBundle identifies an installed plugin-owned page module. The host
|
||||||
|
// validates this declaration before loading a bundle and never selects a game
|
||||||
|
// page by plugin ID.
|
||||||
|
type PluginPageBundle struct {
|
||||||
|
Key string
|
||||||
|
Version string
|
||||||
|
IntegritySHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
// PluginFileWorkspace is a bounded, logical catalog for a plugin-owned files
|
// PluginFileWorkspace is a bounded, logical catalog for a plugin-owned files
|
||||||
// workbench. Keys are logical identifiers, never host paths.
|
// workbench. Keys are logical identifiers, never host paths.
|
||||||
type PluginFileWorkspace struct {
|
type PluginFileWorkspace struct {
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ type GamePluginPageBody struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
BundleKey string `json:"bundleKey"`
|
||||||
|
BundleVersion string `json:"bundleVersion"`
|
||||||
|
BundleIntegritySHA256 string `json:"bundleIntegritySha256"`
|
||||||
Permissions []string `json:"permissions,omitempty"`
|
Permissions []string `json:"permissions,omitempty"`
|
||||||
BridgeActions []string `json:"bridgeActions,omitempty"`
|
BridgeActions []string `json:"bridgeActions,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -1880,6 +1883,7 @@ func pagesToDomain(pages []GamePluginPageBody) []domain.GamePluginPage {
|
|||||||
Key: page.Key,
|
Key: page.Key,
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
Path: page.Path,
|
Path: page.Path,
|
||||||
|
Bundle: domain.PluginPageBundle{Key: page.BundleKey, Version: page.BundleVersion, IntegritySHA256: page.BundleIntegritySHA256},
|
||||||
Permissions: domain.CopyStringSlice(page.Permissions),
|
Permissions: domain.CopyStringSlice(page.Permissions),
|
||||||
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
|
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
|
||||||
}
|
}
|
||||||
@@ -1897,6 +1901,9 @@ func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPageBody {
|
|||||||
Key: page.Key,
|
Key: page.Key,
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
Path: page.Path,
|
Path: page.Path,
|
||||||
|
BundleKey: page.Bundle.Key,
|
||||||
|
BundleVersion: page.Bundle.Version,
|
||||||
|
BundleIntegritySHA256: page.Bundle.IntegritySHA256,
|
||||||
Permissions: domain.CopyStringSlice(page.Permissions),
|
Permissions: domain.CopyStringSlice(page.Permissions),
|
||||||
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
|
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1397,6 +1397,12 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
|
|||||||
if !validPluginPagePath(page.Path) {
|
if !validPluginPagePath(page.Path) {
|
||||||
violations = append(violations, prefix+".path is invalid")
|
violations = append(violations, prefix+".path is invalid")
|
||||||
}
|
}
|
||||||
|
if page.Bundle.Key != "" || page.Bundle.Version != "" || page.Bundle.IntegritySHA256 != "" {
|
||||||
|
violations = appendRequired(violations, prefix+".bundle.key", page.Bundle.Key)
|
||||||
|
violations = appendRequired(violations, prefix+".bundle.version", page.Bundle.Version)
|
||||||
|
violations = appendRequired(violations, prefix+".bundle.integritySha256", page.Bundle.IntegritySHA256)
|
||||||
|
if !validDistributionLogicalKey(page.Bundle.Key) || !validPluginPageBundleVersion(page.Bundle.Version) || !validPluginPageBundleIntegrity(page.Bundle.IntegritySHA256) { violations = append(violations, prefix+".bundle is invalid") }
|
||||||
|
}
|
||||||
if page.Key != "" {
|
if page.Key != "" {
|
||||||
if _, exists := seenKeys[page.Key]; exists {
|
if _, exists := seenKeys[page.Key]; exists {
|
||||||
violations = append(violations, prefix+".key duplicates another page")
|
violations = append(violations, prefix+".key duplicates another page")
|
||||||
@@ -1414,6 +1420,18 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validPluginPageBundleVersion(value string) bool {
|
||||||
|
if len(value) == 0 || len(value) > 80 { return false }
|
||||||
|
for _, item := range value { if !(item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '.' || item == '_' || item == '-') { return false } }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPluginPageBundleIntegrity(value string) bool {
|
||||||
|
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { return false }
|
||||||
|
for _, item := range value[len("sha256:"):] { if !(item >= 'a' && item <= 'f' || item >= '0' && item <= '9') { return false } }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func validatePluginFileWorkspace(prefix string, workspace domain.PluginFileWorkspace) []string {
|
func validatePluginFileWorkspace(prefix string, workspace domain.PluginFileWorkspace) []string {
|
||||||
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) == 0 && len(workspace.Files) == 0 && len(workspace.ConfigFields) == 0 {
|
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) == 0 && len(workspace.Files) == 0 && len(workspace.ConfigFields) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ export interface GamePluginPageResponse {
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
bundleKey?: string;
|
||||||
|
bundleVersion?: string;
|
||||||
|
bundleIntegritySha256?: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
bridgeActions?: string[];
|
bridgeActions?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export interface PluginPageContract {
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
bundleKey?: string;
|
||||||
|
bundleVersion?: string;
|
||||||
|
bundleIntegritySha256?: string;
|
||||||
permissions?: PluginPermission[];
|
permissions?: PluginPermission[];
|
||||||
bridgeActions?: PluginBridgeAction[];
|
bridgeActions?: PluginBridgeAction[];
|
||||||
}
|
}
|
||||||
@@ -101,6 +104,9 @@ export function pluginBridgeManifestContractFromResponse(
|
|||||||
key: page.key,
|
key: page.key,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
path: page.path,
|
path: page.path,
|
||||||
|
bundleKey: page.bundleKey,
|
||||||
|
bundleVersion: page.bundleVersion,
|
||||||
|
bundleIntegritySha256: page.bundleIntegritySha256,
|
||||||
permissions: page.permissions.filter(isPluginPermission),
|
permissions: page.permissions.filter(isPluginPermission),
|
||||||
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction)
|
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction)
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -25,16 +25,19 @@ const plugin: GamePluginResponse = {
|
|||||||
manifestRef: "artifact://manifests/game.scum/1.0.0",
|
manifestRef: "artifact://manifests/game.scum/1.0.0",
|
||||||
createFormSchemaRef: "schemas/create-form.schema.json",
|
createFormSchemaRef: "schemas/create-form.schema.json",
|
||||||
requiredRunCapabilities: [],
|
requiredRunCapabilities: [],
|
||||||
declaredPermissions: ["server.read", "server.logs.read", "server.remote.access", "server.game-client.read", "server.game-client.command"],
|
declaredPermissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"],
|
||||||
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: true },
|
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: false },
|
||||||
lifecycleActions: {},
|
lifecycleActions: {},
|
||||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
|
bridgeActions: ["server.instances.read", "logs.query"],
|
||||||
pages: [{
|
pages: [{
|
||||||
key: "files-config",
|
key: "files-config",
|
||||||
title: "文件与配置",
|
title: "文件与配置",
|
||||||
path: "/files-config",
|
path: "/files-config",
|
||||||
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read", "server.remote.access"],
|
bundleKey: "scum-server-plugin",
|
||||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"]
|
bundleVersion: "1.0.0",
|
||||||
|
bundleIntegritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b",
|
||||||
|
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read"],
|
||||||
|
bridgeActions: ["server.instances.read", "logs.query"]
|
||||||
}],
|
}],
|
||||||
tags: ["scum"],
|
tags: ["scum"],
|
||||||
aiPurposes: [],
|
aiPurposes: [],
|
||||||
@@ -42,10 +45,9 @@ const plugin: GamePluginResponse = {
|
|||||||
gameClientBridge: {
|
gameClientBridge: {
|
||||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
|
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
|
||||||
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
|
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
|
||||||
queryTemplates: [{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }],
|
|
||||||
commandRetentionSeconds: 86400,
|
commandRetentionSeconds: 86400,
|
||||||
maxCommands: 1000,
|
maxCommands: 1000,
|
||||||
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
|
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
|
||||||
},
|
},
|
||||||
status: "installed"
|
status: "installed"
|
||||||
};
|
};
|
||||||
@@ -72,20 +74,19 @@ function props(serverId = "server-1"): PageComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("PluginPageHostPage", () => {
|
describe("PluginPageHostPage", () => {
|
||||||
it("renders SCUM operations from manifest-owned declarations", () => {
|
it("renders a generic manifest-owned bundle declaration", () => {
|
||||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
|
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
|
||||||
expect(html).toContain("文件与配置");
|
expect(html).toContain("文件与配置");
|
||||||
expect(html).toContain("平台托管上下文");
|
expect(html).toContain("平台托管上下文");
|
||||||
expect(html).toContain("命令目录");
|
expect(html).toContain("scum-server-plugin@1.0.0");
|
||||||
expect(html).toContain("快照目录");
|
expect(html).toContain("完整性");
|
||||||
expect(html).toContain("查询模板");
|
|
||||||
expect(html).toContain("返回服务器");
|
expect(html).toContain("返回服务器");
|
||||||
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
|
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a declared availability reason when server context is missing", () => {
|
it("does not mount a bundle without client-side availability validation", () => {
|
||||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
|
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
|
||||||
expect(html).toContain("SCUM 运维声明不可用");
|
expect(html).toContain("未绑定服务器");
|
||||||
expect(html).toContain("缺少服务器实例上下文");
|
expect(html).toContain("正在校验并加载插件页面 bundle");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { ArrowLeft, PlugZap } from "lucide-react";
|
import { ArrowLeft, PlugZap } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import * as React from "react";
|
||||||
|
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { GamePluginResponse } from "../api/types";
|
import type { GamePluginResponse } from "../api/types";
|
||||||
import { PageFrame } from "../components/PageFrame";
|
import { PageFrame } from "../components/PageFrame";
|
||||||
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
|
|
||||||
import { GamePlayerIntelligencePanel } from "../components/GamePlayerIntelligencePanel";
|
|
||||||
import { ScumMapTrajectoryPanel } from "../components/ScumMapTrajectoryPanel";
|
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||||
import { normalizeScumRouteKey, resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
|
|
||||||
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||||
|
import { loadPluginPageBundle } from "../utils/pluginPageBundles";
|
||||||
|
|
||||||
type PluginPageState =
|
type PluginPageState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -24,9 +22,12 @@ interface PluginPageHostPageProps extends PageComponentProps {
|
|||||||
|
|
||||||
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
|
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
|
||||||
const pluginId = params.pluginId ?? "";
|
const pluginId = params.pluginId ?? "";
|
||||||
const routeKey = normalizeScumRouteKey(pluginId, params.routeKey ?? "");
|
const routeKey = params.routeKey ?? "";
|
||||||
const serverId = params.serverId ?? "";
|
const serverId = params.serverId ?? "";
|
||||||
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
|
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
|
||||||
|
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; availability: { available: boolean; reason?: string } }> | null>(null);
|
||||||
|
const [bundleError, setBundleError] = useState("");
|
||||||
|
const [availability, setAvailability] = useState<{ available: boolean; reason?: string }>({ available: false, reason: "正在验证 Companion 可用性。" });
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!pluginId || !routeKey) {
|
if (!pluginId || !routeKey) {
|
||||||
@@ -49,6 +50,20 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
|||||||
}
|
}
|
||||||
}, [initialPlugin, load]);
|
}, [initialPlugin, load]);
|
||||||
|
|
||||||
|
const readyPlugin = state.status === "ready" ? state.plugin : undefined;
|
||||||
|
const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey);
|
||||||
|
const declaredBundlePage = readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin).pages.find((candidate) => candidate.key === routeKey) : undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (!declaredBundlePage) return () => { active = false; };
|
||||||
|
globalThis.__PLUGIN_PAGE_REACT__ = React;
|
||||||
|
setBundle(null); setBundleError("");
|
||||||
|
void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
|
||||||
|
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
|
||||||
|
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { if (active) setAvailability({ available: status.available, reason: status.reason }); }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [declaredBundlePage, serverId]);
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return <LoadingState label="正在加载插件页面声明…" />;
|
return <LoadingState label="正在加载插件页面声明…" />;
|
||||||
}
|
}
|
||||||
@@ -56,7 +71,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
|||||||
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
|
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = state.plugin.pages.find((candidate) => candidate.key === routeKey);
|
const page = declaredPage;
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
|
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
|
||||||
}
|
}
|
||||||
@@ -67,9 +82,6 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
|||||||
serverInstanceId: serverId || undefined,
|
serverInstanceId: serverId || undefined,
|
||||||
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
|
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
|
||||||
});
|
});
|
||||||
const isScumOperations = state.plugin.id === scumOperationsPluginId && routeKey === scumOperationsRouteKey;
|
|
||||||
const scumResolution = isScumOperations ? resolveScumOperationsPageContract(state.plugin, serverId) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="console-page">
|
<div className="console-page">
|
||||||
<PageFrame
|
<PageFrame
|
||||||
@@ -92,24 +104,11 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{scumResolution && !scumResolution.available && <ErrorState title="SCUM 运维声明不可用" reason={scumResolution.reason} compact />}
|
<div className="action-list" aria-label="plugin page declarations"><span><strong>Bundle</strong> {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}</span><span><strong>完整性</strong> {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}</span><span><strong>Companion</strong> {availability.available ? "可用" : "不可用"}</span></div>
|
||||||
{scumResolution?.available && (
|
|
||||||
<div className="action-list" aria-label="SCUM operations declarations">
|
|
||||||
<span><strong>命令目录</strong> {scumResolution.contract.commands.length} 项,均来自插件 operations 页面声明</span>
|
|
||||||
<span><strong>快照目录</strong> {scumResolution.contract.snapshots.length} 项,按插件 schemaVersion 投影</span>
|
|
||||||
<span><strong>查询模板</strong> {scumResolution.contract.queryTemplates.length} 项,仅保留声明式 SQLite 模板引用</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!scumResolution && (
|
|
||||||
<EmptyState
|
|
||||||
title="插件页面已接入 Host Bridge"
|
|
||||||
description="当前页面只接收 manifest 声明与平台安全上下文;具体操作由对应插件页面实现。"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
|
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||||
{scumResolution?.available && <GamePlayerIntelligencePanel serverInstanceId={serverId} />}
|
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||||
{scumResolution?.available && <ScumMapTrajectoryPanel serverInstanceId={serverId} />}
|
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, availability })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1997,6 +1997,9 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
|
|||||||
key: item.key,
|
key: item.key,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
path: item.path,
|
path: item.path,
|
||||||
|
bundleKey: item.bundleKey,
|
||||||
|
bundleVersion: item.bundleVersion,
|
||||||
|
bundleIntegritySha256: item.bundleIntegritySha256,
|
||||||
permissions: item.permissions as PluginBridgeManifestContract["declaredPermissions"],
|
permissions: item.permissions as PluginBridgeManifestContract["declaredPermissions"],
|
||||||
bridgeActions: item.bridgeActions as PluginBridgeAction[] | undefined
|
bridgeActions: item.bridgeActions as PluginBridgeAction[] | undefined
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
import type { PluginBridgeHostContext, PluginPageContract } from "../contracts/pluginBridge";
|
||||||
|
|
||||||
|
export interface PluginPageBundleModule {
|
||||||
|
pluginPageBundle: { key: string; version: string; integritySha256: string };
|
||||||
|
renderPluginPage: (react: { createElement: typeof import("react").createElement }, input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; availability: { available: boolean; reason?: string } }) => ReturnType<typeof import("react").createElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageBundles = import.meta.glob<PluginPageBundleModule>("../../plugins/examples/*/page-bundle/index.ts");
|
||||||
|
|
||||||
|
export async function loadPluginPageBundle(page: PluginPageContract): Promise<ComponentType<{ context: PluginBridgeHostContext; workspace?: unknown; availability: { available: boolean; reason?: string } }>> {
|
||||||
|
if (!page.bundleKey || !page.bundleVersion || !page.bundleIntegritySha256) throw new Error("插件没有声明受验证的页面 bundle。");
|
||||||
|
const match = Object.entries(pageBundles).find(([path]) => path.endsWith(`/${page.bundleKey}/page-bundle/index.ts`));
|
||||||
|
if (!match) throw new Error("已声明的插件页面 bundle 未安装。");
|
||||||
|
const module = await match[1]();
|
||||||
|
if (module.pluginPageBundle.key !== page.bundleKey || module.pluginPageBundle.version !== page.bundleVersion || module.pluginPageBundle.integritySha256 !== page.bundleIntegritySha256) throw new Error("插件页面 bundle 完整性或版本校验失败。");
|
||||||
|
return ({ context, workspace, availability }) => module.renderPluginPage({ createElement: (awaitReact()).createElement }, { page, context, workspace, availability });
|
||||||
|
}
|
||||||
|
|
||||||
|
function awaitReact(): typeof import("react") {
|
||||||
|
// React is supplied by the authenticated platform shell; bundles never own a second runtime.
|
||||||
|
return requireReact();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireReact(): typeof import("react") {
|
||||||
|
return globalThis.__PLUGIN_PAGE_REACT__;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global { var __PLUGIN_PAGE_REACT__: typeof import("react"); }
|
||||||
@@ -66,6 +66,9 @@
|
|||||||
"key": "overview",
|
"key": "overview",
|
||||||
"title": "概览",
|
"title": "概览",
|
||||||
"path": "/overview",
|
"path": "/overview",
|
||||||
|
"bundleKey": "dev-game-plugin",
|
||||||
|
"bundleVersion": "1.0.0",
|
||||||
|
"bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
"permissions": ["server.read"],
|
"permissions": ["server.read"],
|
||||||
"bridgeActions": ["server.instances.read"]
|
"bridgeActions": ["server.instances.read"]
|
||||||
},
|
},
|
||||||
@@ -73,6 +76,9 @@
|
|||||||
"key": "config",
|
"key": "config",
|
||||||
"title": "配置",
|
"title": "配置",
|
||||||
"path": "/config",
|
"path": "/config",
|
||||||
|
"bundleKey": "dev-game-plugin",
|
||||||
|
"bundleVersion": "1.0.0",
|
||||||
|
"bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
"permissions": ["server.files.read", "server.files.write", "ai.invoke"],
|
"permissions": ["server.files.read", "server.files.write", "ai.invoke"],
|
||||||
"bridgeActions": ["files.request", "ai.invoke"]
|
"bridgeActions": ["files.request", "ai.invoke"]
|
||||||
},
|
},
|
||||||
@@ -80,6 +86,9 @@
|
|||||||
"key": "logs",
|
"key": "logs",
|
||||||
"title": "日志",
|
"title": "日志",
|
||||||
"path": "/logs",
|
"path": "/logs",
|
||||||
|
"bundleKey": "dev-game-plugin",
|
||||||
|
"bundleVersion": "1.0.0",
|
||||||
|
"bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
"permissions": ["server.logs.read", "ai.invoke"],
|
"permissions": ["server.logs.read", "ai.invoke"],
|
||||||
"bridgeActions": ["logs.query", "ai.invoke"]
|
"bridgeActions": ["logs.query", "ai.invoke"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,9 @@
|
|||||||
"key": "overview",
|
"key": "overview",
|
||||||
"title": "MC 概览",
|
"title": "MC 概览",
|
||||||
"path": "/overview",
|
"path": "/overview",
|
||||||
|
"bundleKey": "minecraft-server-plugin",
|
||||||
|
"bundleVersion": "1.0.0",
|
||||||
|
"bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"server.read",
|
"server.read",
|
||||||
"server.lifecycle"
|
"server.lifecycle"
|
||||||
@@ -121,6 +124,9 @@
|
|||||||
"key": "remote",
|
"key": "remote",
|
||||||
"title": "MC 远程",
|
"title": "MC 远程",
|
||||||
"path": "/remote",
|
"path": "/remote",
|
||||||
|
"bundleKey": "minecraft-server-plugin",
|
||||||
|
"bundleVersion": "1.0.0",
|
||||||
|
"bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"server.remote.access",
|
"server.remote.access",
|
||||||
"server.files.read",
|
"server.files.read",
|
||||||
@@ -137,6 +143,9 @@
|
|||||||
"key": "rcon",
|
"key": "rcon",
|
||||||
"title": "MC RCON",
|
"title": "MC RCON",
|
||||||
"path": "/rcon",
|
"path": "/rcon",
|
||||||
|
"bundleKey": "minecraft-server-plugin",
|
||||||
|
"bundleVersion": "1.0.0",
|
||||||
|
"bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"server.remote.access"
|
"server.remote.access"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package companion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SafeAdapter is intentionally narrow: it receives typed values only and has
|
||||||
|
// no raw RCON, SQL, host-path, credential, or shell access.
|
||||||
|
type SafeAdapter interface {
|
||||||
|
ReadConfiguration(context.Context) (map[string]any, error)
|
||||||
|
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
|
||||||
|
Diagnostics(context.Context) (map[string]any, error)
|
||||||
|
PatchGameState(context.Context, map[string]any) (map[string]any, error)
|
||||||
|
DeliverReward(context.Context, map[string]any) (map[string]any, error)
|
||||||
|
NotifyPlayer(context.Context, map[string]any) (map[string]any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerAvailability struct { ServerVersion string; Capabilities map[string]bool; Approved bool }
|
||||||
|
type CommandHandler func(context.Context, map[string]any) (map[string]any, error)
|
||||||
|
type HandlerRegistry struct { availability HandlerAvailability; handlers map[string]CommandHandler }
|
||||||
|
|
||||||
|
func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) *HandlerRegistry {
|
||||||
|
registry := &HandlerRegistry{availability: availability, handlers: map[string]CommandHandler{}}
|
||||||
|
if adapter == nil { return registry }
|
||||||
|
registry.handlers["config.read"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { return adapter.ReadConfiguration(ctx) }
|
||||||
|
registry.handlers["config.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.PatchConfiguration(ctx, payload) }
|
||||||
|
registry.handlers["companion.diagnostics"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { return adapter.Diagnostics(ctx) }
|
||||||
|
registry.handlers["game-state.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.PatchGameState(ctx, payload) }
|
||||||
|
registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.DeliverReward(ctx, payload) }
|
||||||
|
registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.NotifyPlayer(ctx, payload) }
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (registry *HandlerRegistry) Execute(ctx context.Context, command ClaimedCommand) (CommandResult, error) {
|
||||||
|
if err := validateDeclaredCommand(command); err != nil { return unsupportedResult("validation-failed"), nil }
|
||||||
|
if !registry.availability.Approved || !registry.availability.Capabilities[command.CommandType] { return unsupportedResult("unsupported"), nil }
|
||||||
|
handler, exists := registry.handlers[command.CommandType]
|
||||||
|
if !exists || strings.TrimSpace(registry.availability.ServerVersion) == "" { return unsupportedResult("unsupported"), nil }
|
||||||
|
payload, err := handler(ctx, command.Payload)
|
||||||
|
if err != nil { return CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}}, nil }
|
||||||
|
return CommandResult{Status: "succeeded", Summary: "typed adapter completed", Payload: redactTypedPayload(payload)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDeclaredCommand(command ClaimedCommand) error {
|
||||||
|
if command.ID == "" || command.ProfileKey != ProfileKey || command.FencingToken == 0 || command.Payload == nil || command.ExpiresAt.IsZero() || !time.Now().Before(command.ExpiresAt) { return fmt.Errorf("invalid command") }
|
||||||
|
for key, value := range command.Payload { if !safeCommandField(key, value) { return fmt.Errorf("unsafe payload") } }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeCommandField(key string, value any) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(key)); if lower == "" || strings.Contains(lower, "path") || strings.Contains(lower, "credential") || strings.Contains(lower, "password") || strings.Contains(lower, "sql") || strings.Contains(lower, "rcon") || strings.Contains(lower, "command") { return false }
|
||||||
|
if text, ok := value.(string); ok { compact := strings.ToLower(text); return !strings.Contains(compact, "bearer ") && !strings.Contains(compact, "password=") && !strings.Contains(compact, "select ") && !strings.Contains(compact, "/users/") }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsupportedResult(code string) CommandResult { return CommandResult{Status: "failed", Summary: "typed operation unavailable", Payload: map[string]any{"result": code}} }
|
||||||
|
func redactTypedPayload(payload map[string]any) map[string]any { result := map[string]any{}; for key, value := range payload { if safeCommandField(key, value) { result[key] = value } }; return result }
|
||||||
|
|
||||||
|
type Dispatcher struct { Client *Client; Registry *HandlerRegistry; PollLimit int; Backoff time.Duration }
|
||||||
|
func (dispatcher Dispatcher) DispatchOnce(ctx context.Context) error {
|
||||||
|
if dispatcher.Client == nil || dispatcher.Registry == nil { return fmt.Errorf("dispatcher is not configured") }
|
||||||
|
limit := dispatcher.PollLimit; if limit == 0 { limit = 10 }; if limit < 1 || limit > 50 { return fmt.Errorf("dispatcher poll limit is invalid") }
|
||||||
|
commands, err := dispatcher.Client.ClaimCommands(ctx, limit); if err != nil { return err }
|
||||||
|
for _, command := range commands { if _, err = dispatcher.Client.AckCommand(ctx, command.ID, command.FencingToken); err != nil { return err }; result, executionErr := dispatcher.Registry.Execute(ctx, command); if executionErr != nil { result = CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}} }; if _, err = dispatcher.Client.CompleteCommand(ctx, command.ID, command.FencingToken, result); err != nil { return err } }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (dispatcher Dispatcher) Run(ctx context.Context) error { backoff := dispatcher.Backoff; if backoff <= 0 { backoff = 2 * time.Second }; for { if err := dispatcher.DispatchOnce(ctx); err != nil { select { case <-ctx.Done(): return ctx.Err(); case <-time.After(backoff): continue } }; select { case <-ctx.Done(): return ctx.Err(); case <-time.After(backoff): } } }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package companion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const semanticEventsSnapshotType = "semantic.events"
|
||||||
|
const semanticEventsSchemaVersion = "1"
|
||||||
|
const semanticEventsRetentionSeconds = 7 * 24 * 60 * 60
|
||||||
|
const semanticEventsMaxRecords = 1000
|
||||||
|
|
||||||
|
type SemanticEvent struct { Type string `json:"type"`; OccurredAt time.Time `json:"occurredAt"`; PlayerID string `json:"playerId,omitempty"`; DisplayName string `json:"displayName,omitempty"`; NetworkCorrelation string `json:"networkCorrelation,omitempty"` }
|
||||||
|
var supportedLoginLine = regexp.MustCompile(`^LOGIN player=([A-Za-z0-9._:-]{1,96}) name=([^\n]{1,80}) at=([0-9TZ:+.-]{20,40})(?: network=([^\s]{1,128}))?$`)
|
||||||
|
var supportedLogoutLine = regexp.MustCompile(`^LOGOUT player=([A-Za-z0-9._:-]{1,96}) at=([0-9TZ:+.-]{20,40})$`)
|
||||||
|
|
||||||
|
// ParseSemanticEvent supports only versioned, allow-listed extension output.
|
||||||
|
// Unknown formats deliberately yield no event and may be reported as a bounded
|
||||||
|
// diagnostic by the caller.
|
||||||
|
func ParseSemanticEvent(line string, serverCorrelationKey []byte) (SemanticEvent, bool) {
|
||||||
|
if match := supportedLoginLine.FindStringSubmatch(strings.TrimSpace(line)); len(match) != 0 { occurred, err := time.Parse(time.RFC3339, match[3]); if err != nil { return SemanticEvent{}, false }; event := SemanticEvent{Type: "scum.login", PlayerID: match[1], DisplayName: match[2], OccurredAt: occurred}; if match[4] != "" { event.NetworkCorrelation = irreversibleServerCorrelation(serverCorrelationKey, match[4]) }; return event, true }
|
||||||
|
if match := supportedLogoutLine.FindStringSubmatch(strings.TrimSpace(line)); len(match) != 0 { occurred, err := time.Parse(time.RFC3339, match[2]); if err != nil { return SemanticEvent{}, false }; return SemanticEvent{Type: "scum.logout", PlayerID: match[1], OccurredAt: occurred}, true }
|
||||||
|
return SemanticEvent{}, false
|
||||||
|
}
|
||||||
|
func irreversibleServerCorrelation(key []byte, source string) string { if len(key) == 0 || source == "" { return "" }; mac := hmac.New(sha256.New, key); _, _ = mac.Write([]byte(source)); return hex.EncodeToString(mac.Sum(nil)) }
|
||||||
|
func (client *Client) UploadSemanticEvents(ctx context.Context, streamKey string, sequence uint64, events []SemanticEvent) (AcceptedSnapshot, error) { if len(events) == 0 || len(events) > 100 { return AcceptedSnapshot{}, fmt.Errorf("semantic event batch is invalid") }; payload := map[string]any{"events": events}; return client.UploadSnapshot(ctx, Snapshot{Type: semanticEventsSnapshotType, SchemaVersion: semanticEventsSchemaVersion, StreamKey: streamKey, Sequence: sequence, ObservedAt: client.now().UTC(), Payload: payload, KeepForSeconds: semanticEventsRetentionSeconds, MaxRecords: semanticEventsMaxRecords}) }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { SCUMFeatureAvailability } from "./contracts";
|
||||||
|
|
||||||
|
export interface PluginHostAPI { getCompanionAvailability(serverInstanceId: string): Promise<{ available: boolean; reason?: string }>; }
|
||||||
|
export async function getSCUMFeatureAvailability(host: PluginHostAPI, serverInstanceId: string, feature: SCUMFeatureAvailability["feature"]): Promise<SCUMFeatureAvailability> { const value = await host.getCompanionAvailability(serverInstanceId); return { feature, available: value.available, reason: value.reason }; }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type SCUMFeatureKey = "configuration" | "players" | "rewards" | "state-patches" | "trajectories";
|
||||||
|
export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string; serverVersion?: string };
|
||||||
|
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: "plugin" | "transitional-read-only"; payload: T; recordedAt: string };
|
||||||
|
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed"; summary: string; audit?: Record<string, unknown> };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { SCUMMigrationRecord } from "./contracts";
|
||||||
|
|
||||||
|
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", payload, recordedAt }; }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
const supportedFields: Record<string, readonly string[]> = { "0.9.700.90357": ["skills.running", "attributes.strength"] };
|
||||||
|
export function supportsStateField(serverVersion: string, field: string): boolean { return supportedFields[serverVersion]?.includes(field) ?? false; }
|
||||||
|
export function featureUnavailable(reason: string): { available: false; reason: string } { return { available: false, reason }; }
|
||||||
@@ -46,10 +46,7 @@
|
|||||||
"remote.run.files.write",
|
"remote.run.files.write",
|
||||||
"remote.run.process.start",
|
"remote.run.process.start",
|
||||||
"remote.run.process.stop",
|
"remote.run.process.stop",
|
||||||
"remote.run.db.mysql.query",
|
|
||||||
"remote.run.db.sqlite.query",
|
|
||||||
"remote.run.logs.transfer",
|
"remote.run.logs.transfer",
|
||||||
"remote.run.rcon.command",
|
|
||||||
"client-manager.deploy",
|
"client-manager.deploy",
|
||||||
"client-manager.control",
|
"client-manager.control",
|
||||||
"client-manager.update",
|
"client-manager.update",
|
||||||
@@ -70,16 +67,8 @@
|
|||||||
"remote.run.files.write",
|
"remote.run.files.write",
|
||||||
"remote.run.process.start",
|
"remote.run.process.start",
|
||||||
"remote.run.process.stop",
|
"remote.run.process.stop",
|
||||||
"remote.run.db.mysql.query",
|
"remote.run.logs.transfer"
|
||||||
"remote.run.db.sqlite.query",
|
|
||||||
"remote.run.logs.transfer",
|
|
||||||
"remote.run.rcon.command"
|
|
||||||
],
|
],
|
||||||
"databaseEngines": [
|
|
||||||
"mysql",
|
|
||||||
"sqlite"
|
|
||||||
],
|
|
||||||
"rcon": true,
|
|
||||||
"logTransfer": true
|
"logTransfer": true
|
||||||
},
|
},
|
||||||
"bridge": {
|
"bridge": {
|
||||||
@@ -100,6 +89,8 @@
|
|||||||
},
|
},
|
||||||
"gameClientBridge": {
|
"gameClientBridge": {
|
||||||
"commands": [
|
"commands": [
|
||||||
|
{ "type": "config.read", "title": "Read SCUM configuration", "permission": "server.game-client.read", "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/config-read.payload.schema.json", "resultSchemaRef": "schemas/bridge/config-read.result.schema.json", "timeoutSeconds": 30, "maxPayloadBytes": 1024 },
|
||||||
|
{ "type": "config.patch", "title": "Patch SCUM configuration", "permission": "server.game-client.maintenance", "approvalLevel": "platform-admin", "payloadSchemaRef": "schemas/bridge/config-patch.payload.schema.json", "resultSchemaRef": "schemas/bridge/config-patch.result.schema.json", "timeoutSeconds": 60, "maxPayloadBytes": 4096 },
|
||||||
{
|
{
|
||||||
"type": "announcement.send",
|
"type": "announcement.send",
|
||||||
"title": "Send SCUM announcement",
|
"title": "Send SCUM announcement",
|
||||||
@@ -199,6 +190,13 @@
|
|||||||
"keepForSeconds": 604800,
|
"keepForSeconds": 604800,
|
||||||
"maxRecords": 1000
|
"maxRecords": 1000
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "semantic.events",
|
||||||
|
"schemaVersion": "1",
|
||||||
|
"schemaRef": "schemas/bridge/semantic-events.snapshot.schema.json",
|
||||||
|
"keepForSeconds": 604800,
|
||||||
|
"maxRecords": 1000
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "online.sessions",
|
"type": "online.sessions",
|
||||||
"schemaVersion": "1",
|
"schemaVersion": "1",
|
||||||
@@ -242,68 +240,6 @@
|
|||||||
"maxRecords": 1000
|
"maxRecords": 1000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"queryTemplates": [
|
|
||||||
{
|
|
||||||
"key": "scum.player.by-id",
|
|
||||||
"title": "Find SCUM player by ID",
|
|
||||||
"permission": "server.game-client.read",
|
|
||||||
"engine": "sqlite",
|
|
||||||
"transportKey": "sqlite-db",
|
|
||||||
"targetKey": "db/sqlite",
|
|
||||||
"parameterSchemaRef": "schemas/bridge/queries/player-by-id.parameters.schema.json",
|
|
||||||
"resultSchemaRef": "schemas/bridge/queries/player-by-id.result.schema.json",
|
|
||||||
"maxRows": 1,
|
|
||||||
"timeoutSeconds": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "scum.player.search",
|
|
||||||
"title": "Search SCUM players",
|
|
||||||
"permission": "server.game-client.read",
|
|
||||||
"engine": "sqlite",
|
|
||||||
"transportKey": "sqlite-db",
|
|
||||||
"targetKey": "db/sqlite",
|
|
||||||
"parameterSchemaRef": "schemas/bridge/queries/player-search.parameters.schema.json",
|
|
||||||
"resultSchemaRef": "schemas/bridge/queries/player-search.result.schema.json",
|
|
||||||
"maxRows": 50,
|
|
||||||
"timeoutSeconds": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "scum.squad.members",
|
|
||||||
"title": "List SCUM squad members",
|
|
||||||
"permission": "server.game-client.read",
|
|
||||||
"engine": "sqlite",
|
|
||||||
"transportKey": "sqlite-db",
|
|
||||||
"targetKey": "db/sqlite",
|
|
||||||
"parameterSchemaRef": "schemas/bridge/queries/squad-members.parameters.schema.json",
|
|
||||||
"resultSchemaRef": "schemas/bridge/queries/squad-members.result.schema.json",
|
|
||||||
"maxRows": 64,
|
|
||||||
"timeoutSeconds": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "scum.vehicle.owner",
|
|
||||||
"title": "Find SCUM vehicle owner",
|
|
||||||
"permission": "server.game-client.read",
|
|
||||||
"engine": "sqlite",
|
|
||||||
"transportKey": "sqlite-db",
|
|
||||||
"targetKey": "db/sqlite",
|
|
||||||
"parameterSchemaRef": "schemas/bridge/queries/vehicle-owner.parameters.schema.json",
|
|
||||||
"resultSchemaRef": "schemas/bridge/queries/vehicle-owner.result.schema.json",
|
|
||||||
"maxRows": 1,
|
|
||||||
"timeoutSeconds": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "scum.flag.ownership",
|
|
||||||
"title": "Find SCUM flag ownership",
|
|
||||||
"permission": "server.game-client.read",
|
|
||||||
"engine": "sqlite",
|
|
||||||
"transportKey": "sqlite-db",
|
|
||||||
"targetKey": "db/sqlite",
|
|
||||||
"parameterSchemaRef": "schemas/bridge/queries/flag-ownership.parameters.schema.json",
|
|
||||||
"resultSchemaRef": "schemas/bridge/queries/flag-ownership.result.schema.json",
|
|
||||||
"maxRows": 1,
|
|
||||||
"timeoutSeconds": 10
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"commandRetentionSeconds": 604800,
|
"commandRetentionSeconds": 604800,
|
||||||
"maxCommands": 1000,
|
"maxCommands": 1000,
|
||||||
"pages": [
|
"pages": [
|
||||||
@@ -318,14 +254,7 @@
|
|||||||
"restart.prepare",
|
"restart.prepare",
|
||||||
"maintenance.prepare"
|
"maintenance.prepare"
|
||||||
],
|
],
|
||||||
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
|
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]
|
||||||
"queryTemplateKeys": [
|
|
||||||
"scum.player.by-id",
|
|
||||||
"scum.player.search",
|
|
||||||
"scum.squad.members",
|
|
||||||
"scum.vehicle.owner",
|
|
||||||
"scum.flag.ownership"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"companion": {
|
"companion": {
|
||||||
@@ -374,7 +303,7 @@
|
|||||||
"dependencyPolicy": "required",
|
"dependencyPolicy": "required",
|
||||||
"approvalRequired": ["disable", "rollback", "retire"]
|
"approvalRequired": ["disable", "rollback", "retire"]
|
||||||
},
|
},
|
||||||
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.remote.access", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "remote.access.request", "ai.invoke"] }],
|
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"] }],
|
||||||
"fileWorkspace": {
|
"fileWorkspace": {
|
||||||
"defaultDirectoryKey": "scum-config",
|
"defaultDirectoryKey": "scum-config",
|
||||||
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
|
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
|
||||||
@@ -419,8 +348,7 @@
|
|||||||
"process.restart",
|
"process.restart",
|
||||||
"process.status",
|
"process.status",
|
||||||
"remote.run.process.start",
|
"remote.run.process.start",
|
||||||
"remote.run.process.stop",
|
"remote.run.process.stop"
|
||||||
"remote.run.rcon.command"
|
|
||||||
],
|
],
|
||||||
"actionRefs": {
|
"actionRefs": {
|
||||||
"install": "actions/install.json",
|
"install": "actions/install.json",
|
||||||
@@ -430,33 +358,12 @@
|
|||||||
"status": "actions/status.json"
|
"status": "actions/status.json"
|
||||||
},
|
},
|
||||||
"transportKeys": [
|
"transportKeys": [
|
||||||
"server-files",
|
"server-files"
|
||||||
"sqlite-db",
|
|
||||||
"mysql-db",
|
|
||||||
"rcon"
|
|
||||||
],
|
],
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"windows"
|
"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",
|
"key": "scum-client",
|
||||||
"mode": "custom-client",
|
"mode": "custom-client",
|
||||||
@@ -759,38 +666,6 @@
|
|||||||
"remote.rsync.read",
|
"remote.rsync.read",
|
||||||
"remote.rsync.write"
|
"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": [
|
"clientManagers": [
|
||||||
@@ -858,28 +733,6 @@
|
|||||||
"retainPrevious": true
|
"retainPrevious": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"dllExtensions": [
|
|
||||||
{
|
|
||||||
"key": "scum-simple-rcon-ue4ss",
|
|
||||||
"displayName": "SCUM Simple RCON UE4SS DLL",
|
|
||||||
"kind": "ue4ss-dll",
|
|
||||||
"activation": "server-start",
|
|
||||||
"version": "0.1.0-unpublished",
|
|
||||||
"releaseState": "unpublished",
|
|
||||||
"releaseUrl": "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll",
|
|
||||||
"targetKey": "ue4ss/scum-simple-rcon",
|
|
||||||
"modKey": "scum_simple_rcon",
|
|
||||||
"dllRef": "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
|
|
||||||
"supportedTargets": [
|
|
||||||
{
|
|
||||||
"os": "windows",
|
|
||||||
"arch": "amd64"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"updateOnStart": true,
|
|
||||||
"rconPort": 27015
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.0", integritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b" };
|
||||||
|
|
||||||
|
export function renderPluginPage(react: any, input: any) {
|
||||||
|
const e = react.createElement;
|
||||||
|
const workspace = input.workspace as { configFields?: Array<{ key: string; label: string; description: string; control: string; restartImpact: string }> } | undefined;
|
||||||
|
const state = input.availability.available ? "已声明且已由 Companion 验证" : `不可用:${input.availability.reason || "没有兼容的 Companion 处理器或事件生产者"}`;
|
||||||
|
const fields = workspace?.configFields || [];
|
||||||
|
return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" },
|
||||||
|
e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "此页面由 SCUM 插件 bundle 提供;平台仅提供已授权、服务器隔离的宿主上下文。")), e("span", { className: "page-status" }, state)),
|
||||||
|
e("div", { className: "console-row-list" },
|
||||||
|
e("div", { className: "console-row" }, e("strong", null, "玩家与会话"), e("span", null, "仅显示 Companion 上传的已验证语义事件;没有受支持来源时保持不可用。")),
|
||||||
|
e("div", { className: "console-row" }, e("strong", null, "奖励与通知"), e("span", null, "冻结审批后的 revision;未知投递结果绝不自动重试,通知失败不会重复投递物品。")),
|
||||||
|
e("div", { className: "console-row" }, e("strong", null, "状态修改"), e("span", null, "仅在已发现版本、已验证安全窗口和处理器可用时开放。")),
|
||||||
|
e("div", { className: "console-row" }, e("strong", null, "轨迹"), e("span", null, "只接受声明的服务器侧事件源;不会使用 OCR、截图或桌面自动化。")))),
|
||||||
|
e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("h2", null, "版本化配置字段目录"), e("span", { className: "page-status" }, `${fields.length} 项`)), e("div", { className: "console-row-list" }, fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.description} · ${field.control} · ${field.restartImpact === "restart-required" ? "修改后需重启" : "无需重启"}`)))))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type":"object","additionalProperties":false,"required":["revision","fields"],"properties":{"revision":{"type":"string","minLength":1,"maxLength":80},"fields":{"type":"array","maxItems":32,"items":{"type":"object","additionalProperties":false,"required":["key","value"],"properties":{"key":{"type":"string","pattern":"^[A-Za-z][A-Za-z0-9_.-]{0,119}$","maxLength":120},"value":{"type":"string","maxLength":256}}}}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type":"object","additionalProperties":false,"required":["result"],"properties":{"result":{"type":"string","enum":["applied","unsupported","failed"],"maxLength":16},"revision":{"type":"string","maxLength":80}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type":"object","additionalProperties":false,"properties":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type":"object","additionalProperties":false,"required":["result"],"properties":{"result":{"type":"string","enum":["available","unsupported","failed"],"maxLength":16}}}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"type":"object","additionalProperties":false,"required":["events"],"properties":{"events":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","additionalProperties":false,"required":["type","occurredAt"],"properties":{"type":{"type":"string","enum":["scum.login","scum.logout"],"maxLength":16},"occurredAt":{"type":"string","minLength":20,"maxLength":40},"playerId":{"type":"string","minLength":1,"maxLength":96},"displayName":{"type":"string","minLength":1,"maxLength":80},"networkCorrelation":{"type":"string","pattern":"^[a-f0-9]{64}$","maxLength":64}}}}}}
|
||||||
@@ -180,12 +180,15 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["key", "title", "path"],
|
"required": ["key", "title", "path", "bundleKey", "bundleVersion", "bundleIntegritySha256"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
"key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
||||||
"title": { "type": "string", "minLength": 1, "maxLength": 40 },
|
"title": { "type": "string", "minLength": 1, "maxLength": 40 },
|
||||||
"path": { "type": "string", "pattern": "^/[a-z0-9_./-]*$" },
|
"path": { "type": "string", "pattern": "^/[a-z0-9_./-]*$" },
|
||||||
|
"bundleKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$" },
|
||||||
|
"bundleVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" },
|
||||||
|
"bundleIntegritySha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
|
||||||
"permissions": { "type": "array", "items": { "$ref": "#/$defs/pluginPermission" }, "uniqueItems": true },
|
"permissions": { "type": "array", "items": { "$ref": "#/$defs/pluginPermission" }, "uniqueItems": true },
|
||||||
"bridgeActions": { "type": "array", "items": { "$ref": "#/$defs/bridgeAction" }, "uniqueItems": true }
|
"bridgeActions": { "type": "array", "items": { "$ref": "#/$defs/bridgeAction" }, "uniqueItems": true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -615,6 +615,9 @@ export interface GamePluginPage {
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
bundleKey: string;
|
||||||
|
bundleVersion: string;
|
||||||
|
bundleIntegritySha256: `sha256:${string}`;
|
||||||
permissions?: PluginPermission[];
|
permissions?: PluginPermission[];
|
||||||
bridgeActions?: PluginBridgeAction[];
|
bridgeActions?: PluginBridgeAction[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,68 +199,18 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
|
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a pinned ready SCUM UE4SS DLL release and lifecycle reference", () => {
|
it("removes direct RCON, database, and DLL extension declarations", () => {
|
||||||
const errors = validateTemporaryScumCompanionManifest((manifest) => {
|
|
||||||
const extension = manifest.runtimeProfiles.dllExtensions[0];
|
|
||||||
extension.releaseState = "ready";
|
|
||||||
extension.version = "0.1.0";
|
|
||||||
extension.checksum = `sha256:${"a".repeat(64)}`;
|
|
||||||
extension.sizeBytes = 1048576;
|
|
||||||
extension.scumExecutableChecksum = `sha256:${"b".repeat(64)}`;
|
|
||||||
extension.ue4ssAbi = "ue4ss-3.0";
|
|
||||||
manifest.runtimeProfiles.lifecycleProfiles[0].dllExtensionRefs = [extension.key];
|
|
||||||
});
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("declares Source RCON for the Windows local lifecycle without activating the unpublished DLL", () => {
|
|
||||||
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||||
runtimeProfiles?: {
|
runtimeProfiles?: {
|
||||||
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[]; dllExtensionRefs?: string[] }>;
|
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>;
|
||||||
dllExtensions?: Array<{ key: string; releaseState: string }>;
|
transportProfiles?: Array<{ kind?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
|
const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
|
||||||
const extension = manifest.runtimeProfiles?.dllExtensions?.find((candidate) => candidate.key === "scum-simple-rcon-ue4ss");
|
expect(local?.capabilities).not.toContain("remote.run.rcon.command");
|
||||||
|
expect(local?.transportKeys).not.toContain("rcon");
|
||||||
expect(local?.capabilities).toContain("remote.run.rcon.command");
|
expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" || profile.kind === "mysql" || profile.kind === "rcon")).toBe(false);
|
||||||
expect(local?.transportKeys).toContain("rcon");
|
|
||||||
expect(local?.dllExtensionRefs).toBeUndefined();
|
|
||||||
expect(extension?.releaseState).toBe("unpublished");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unpinned, unsafe, or unpublished SCUM UE4SS DLL activation", () => {
|
|
||||||
const missingPins = validateTemporaryScumCompanionManifest((manifest) => {
|
|
||||||
const extension = manifest.runtimeProfiles.dllExtensions[0];
|
|
||||||
extension.releaseState = "ready";
|
|
||||||
manifest.runtimeProfiles.lifecycleProfiles[0].dllExtensionRefs = [extension.key];
|
|
||||||
});
|
|
||||||
expect(missingPins.some((error) => error.includes("checksum") || error.includes("sizeBytes") || error.includes("ue4ssAbi"))).toBe(true);
|
|
||||||
|
|
||||||
const unsafeURL = validateTemporaryScumCompanionManifest((manifest) => {
|
|
||||||
const extension = manifest.runtimeProfiles.dllExtensions[0];
|
|
||||||
extension.releaseState = "ready";
|
|
||||||
extension.releaseUrl = "https://127.0.0.1/scum.exe";
|
|
||||||
extension.checksum = `sha256:${"a".repeat(64)}`;
|
|
||||||
extension.sizeBytes = 1048576;
|
|
||||||
extension.scumExecutableChecksum = `sha256:${"b".repeat(64)}`;
|
|
||||||
extension.ue4ssAbi = "ue4ss-3.0";
|
|
||||||
});
|
|
||||||
expect(unsafeURL.some((error) => error.includes("releaseUrl"))).toBe(true);
|
|
||||||
|
|
||||||
const queryURL = validateTemporaryScumCompanionManifest((manifest) => {
|
|
||||||
manifest.runtimeProfiles.dllExtensions[0].releaseUrl = "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll?release=.dll";
|
|
||||||
});
|
|
||||||
expect(queryURL.some((error) => error.includes("releaseUrl"))).toBe(true);
|
|
||||||
|
|
||||||
const unpublishedReference = validateTemporaryScumCompanionManifest((manifest) => {
|
|
||||||
const extension = manifest.runtimeProfiles.dllExtensions[0];
|
|
||||||
manifest.runtimeProfiles.lifecycleProfiles[0].dllExtensionRefs = [extension.key];
|
|
||||||
manifest.runtimeProfiles.lifecycleProfiles[0].platforms = ["windows", "linux"];
|
|
||||||
});
|
|
||||||
expect(unpublishedReference.some((error) => error.includes("not ready for activation"))).toBe(true);
|
|
||||||
expect(unpublishedReference.some((error) => error.includes("windows local-process"))).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defines a generated SCUM companion config without inline proof or session material", () => {
|
it("defines a generated SCUM companion config without inline proof or session material", () => {
|
||||||
@@ -591,7 +541,7 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes));
|
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("declares read-only bounded SCUM database query templates", () => {
|
it("does not declare direct database query templates", () => {
|
||||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
@@ -612,62 +562,12 @@ describe("plugin manifest validation", () => {
|
|||||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||||
};
|
};
|
||||||
const expectedKeys = ["scum.player.by-id", "scum.player.search", "scum.squad.members", "scum.vehicle.owner", "scum.flag.ownership"];
|
|
||||||
const transport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "sqlite-db");
|
|
||||||
|
|
||||||
expect(transport).toMatchObject({ kind: "sqlite", targetKey: "db/sqlite" });
|
|
||||||
expect(transport?.capabilities).toContain("remote.run.db.sqlite.query");
|
|
||||||
expect(manifest.gameClientBridge.queryTemplates.map((template) => template.key)).toEqual(expect.arrayContaining(expectedKeys));
|
|
||||||
expect(new Set(manifest.gameClientBridge.queryTemplates.map((template) => template.key)).size).toBe(manifest.gameClientBridge.queryTemplates.length);
|
|
||||||
for (const template of manifest.gameClientBridge.queryTemplates) {
|
|
||||||
expect(template.engine).toBe("sqlite");
|
|
||||||
expect(template.permission).toBe("server.game-client.read");
|
|
||||||
expect(manifest.permissions).toContain(template.permission);
|
|
||||||
expect(template.transportKey).toBe("sqlite-db");
|
|
||||||
expect(template.targetKey).toBe("db/sqlite");
|
|
||||||
expect(template.maxRows).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(template.maxRows).toBeLessThanOrEqual(500);
|
|
||||||
expect(template.timeoutSeconds).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(template.timeoutSeconds).toBeLessThanOrEqual(60);
|
|
||||||
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/\bselect\b|\binsert\b|\bupdate\b|\bdelete\b|\bpragma\b|dsn|hostpath|socket|password|credential/);
|
|
||||||
|
|
||||||
for (const schemaRef of [template.parameterSchemaRef, template.resultSchemaRef]) {
|
|
||||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>;
|
|
||||||
const visit = (value: unknown): void => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach(visit);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof value !== "object" || value === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
if (record.type === "object" || Object.hasOwn(record, "properties")) {
|
|
||||||
expect(record.additionalProperties).toBe(false);
|
|
||||||
}
|
|
||||||
if (record.type === "array") {
|
|
||||||
expect(record.maxItems).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
if (record.type === "string") {
|
|
||||||
expect(record.maxLength).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
if (record.type === "integer" || record.type === "number") {
|
|
||||||
expect(record.maximum).toBeDefined();
|
|
||||||
}
|
|
||||||
Object.values(record).forEach(visit);
|
|
||||||
};
|
|
||||||
expect(schema.type).toBe("object");
|
|
||||||
expect(schema.additionalProperties).toBe(false);
|
|
||||||
expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/\bselect\b[\s\S]*\bfrom\b|\binsert\s+into\b|\bdelete\s+from\b|\bpragma\b|dsn|hostpath|runsocket|password|credential/);
|
|
||||||
visit(schema);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
|
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
|
||||||
const operationsPluginPage = manifest.pages.find((page) => page.key === "files-config");
|
const operationsPluginPage = manifest.pages.find((page) => page.key === "files-config");
|
||||||
expect(operationsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(expectedKeys));
|
expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]);
|
||||||
expect(operationsPluginPage?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.remote.access"]));
|
expect(operationsPage?.queryTemplateKeys ?? []).toEqual([]);
|
||||||
expect(operationsPluginPage?.bridgeActions).toContain("remote.access.request");
|
expect(operationsPluginPage?.permissions).not.toContain("server.remote.access");
|
||||||
|
expect(operationsPluginPage?.bridgeActions).not.toContain("remote.access.request");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user