feat(plugin): add SCUM ownership migration foundation

This commit is contained in:
npc0-hue
2026-07-28 18:11:00 +08:00
parent d24074134a
commit 271e1e684f
29 changed files with 328 additions and 315 deletions
@@ -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.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.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.
+10
View File
@@ -312,10 +312,20 @@ type GamePluginPage struct {
Key string
Title string
Path string
Bundle PluginPageBundle
Permissions []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
// workbench. Keys are logical identifiers, never host paths.
type PluginFileWorkspace struct {
+7
View File
@@ -179,6 +179,9 @@ type GamePluginPageBody struct {
Key string `json:"key"`
Title string `json:"title"`
Path string `json:"path"`
BundleKey string `json:"bundleKey"`
BundleVersion string `json:"bundleVersion"`
BundleIntegritySHA256 string `json:"bundleIntegritySha256"`
Permissions []string `json:"permissions,omitempty"`
BridgeActions []string `json:"bridgeActions,omitempty"`
}
@@ -1880,6 +1883,7 @@ func pagesToDomain(pages []GamePluginPageBody) []domain.GamePluginPage {
Key: page.Key,
Title: page.Title,
Path: page.Path,
Bundle: domain.PluginPageBundle{Key: page.BundleKey, Version: page.BundleVersion, IntegritySHA256: page.BundleIntegritySHA256},
Permissions: domain.CopyStringSlice(page.Permissions),
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
}
@@ -1897,6 +1901,9 @@ func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPageBody {
Key: page.Key,
Title: page.Title,
Path: page.Path,
BundleKey: page.Bundle.Key,
BundleVersion: page.Bundle.Version,
BundleIntegritySHA256: page.Bundle.IntegritySHA256,
Permissions: domain.CopyStringSlice(page.Permissions),
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
}
+18
View File
@@ -1397,6 +1397,12 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
if !validPluginPagePath(page.Path) {
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 _, exists := seenKeys[page.Key]; exists {
violations = append(violations, prefix+".key duplicates another page")
@@ -1414,6 +1420,18 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
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 {
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) == 0 && len(workspace.Files) == 0 && len(workspace.ConfigFields) == 0 {
return nil
+3
View File
@@ -219,6 +219,9 @@ export interface GamePluginPageResponse {
key: string;
title: string;
path: string;
bundleKey?: string;
bundleVersion?: string;
bundleIntegritySha256?: string;
permissions: string[];
bridgeActions?: string[];
}
+6
View File
@@ -78,6 +78,9 @@ export interface PluginPageContract {
key: string;
title: string;
path: string;
bundleKey?: string;
bundleVersion?: string;
bundleIntegritySha256?: string;
permissions?: PluginPermission[];
bridgeActions?: PluginBridgeAction[];
}
@@ -101,6 +104,9 @@ export function pluginBridgeManifestContractFromResponse(
key: page.key,
title: page.title,
path: page.path,
bundleKey: page.bundleKey,
bundleVersion: page.bundleVersion,
bundleIntegritySha256: page.bundleIntegritySha256,
permissions: page.permissions.filter(isPluginPermission),
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction)
})),
+15 -14
View File
@@ -25,16 +25,19 @@ const plugin: GamePluginResponse = {
manifestRef: "artifact://manifests/game.scum/1.0.0",
createFormSchemaRef: "schemas/create-form.schema.json",
requiredRunCapabilities: [],
declaredPermissions: ["server.read", "server.logs.read", "server.remote.access", "server.game-client.read", "server.game-client.command"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: true },
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: false },
lifecycleActions: {},
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
bridgeActions: ["server.instances.read", "logs.query"],
pages: [{
key: "files-config",
title: "文件与配置",
path: "/files-config",
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"]
bundleKey: "scum-server-plugin",
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"],
aiPurposes: [],
@@ -42,10 +45,9 @@ const plugin: GamePluginResponse = {
gameClientBridge: {
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 }],
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,
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"
};
@@ -72,20 +74,19 @@ function props(serverId = "server-1"): PageComponentProps {
}
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} />);
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).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} />);
expect(html).toContain("SCUM 运维声明不可用");
expect(html).toContain("缺少服务器实例上下文");
expect(html).toContain("未绑定服务器");
expect(html).toContain("正在校验并加载插件页面 bundle");
});
});
+26 -27
View File
@@ -1,17 +1,15 @@
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 type { GamePluginResponse } from "../api/types";
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 type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { normalizeScumRouteKey, resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import { loadPluginPageBundle } from "../utils/pluginPageBundles";
type PluginPageState =
| { status: "loading" }
@@ -24,9 +22,12 @@ interface PluginPageHostPageProps extends PageComponentProps {
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
const pluginId = params.pluginId ?? "";
const routeKey = normalizeScumRouteKey(pluginId, params.routeKey ?? "");
const routeKey = params.routeKey ?? "";
const serverId = params.serverId ?? "";
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 () => {
if (!pluginId || !routeKey) {
@@ -49,6 +50,20 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
}
}, [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") {
return <LoadingState label="正在加载插件页面声明…" />;
}
@@ -56,7 +71,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
}
const page = state.plugin.pages.find((candidate) => candidate.key === routeKey);
const page = declaredPage;
if (!page) {
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
}
@@ -67,9 +82,6 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
serverInstanceId: serverId || undefined,
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
});
const isScumOperations = state.plugin.id === scumOperationsPluginId && routeKey === scumOperationsRouteKey;
const scumResolution = isScumOperations ? resolveScumOperationsPageContract(state.plugin, serverId) : null;
return (
<div className="console-page">
<PageFrame
@@ -92,24 +104,11 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
</button>
</div>
{scumResolution && !scumResolution.available && <ErrorState title="SCUM 运维声明不可用" reason={scumResolution.reason} compact />}
{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 声明与平台安全上下文;具体操作由对应插件页面实现。"
/>
)}
<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>
</section>
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
{scumResolution?.available && <GamePlayerIntelligencePanel serverInstanceId={serverId} />}
{scumResolution?.available && <ScumMapTrajectoryPanel serverInstanceId={serverId} />}
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, availability })}
</div>
);
}
+3
View File
@@ -1997,6 +1997,9 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
key: item.key,
title: item.title,
path: item.path,
bundleKey: item.bundleKey,
bundleVersion: item.bundleVersion,
bundleIntegritySha256: item.bundleIntegritySha256,
permissions: item.permissions as PluginBridgeManifestContract["declaredPermissions"],
bridgeActions: item.bridgeActions as PluginBridgeAction[] | undefined
})),
+30
View File
@@ -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",
"title": "概览",
"path": "/overview",
"bundleKey": "dev-game-plugin",
"bundleVersion": "1.0.0",
"bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"permissions": ["server.read"],
"bridgeActions": ["server.instances.read"]
},
@@ -73,6 +76,9 @@
"key": "config",
"title": "配置",
"path": "/config",
"bundleKey": "dev-game-plugin",
"bundleVersion": "1.0.0",
"bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"permissions": ["server.files.read", "server.files.write", "ai.invoke"],
"bridgeActions": ["files.request", "ai.invoke"]
},
@@ -80,6 +86,9 @@
"key": "logs",
"title": "日志",
"path": "/logs",
"bundleKey": "dev-game-plugin",
"bundleVersion": "1.0.0",
"bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"permissions": ["server.logs.read", "ai.invoke"],
"bridgeActions": ["logs.query", "ai.invoke"]
}
@@ -108,6 +108,9 @@
"key": "overview",
"title": "MC 概览",
"path": "/overview",
"bundleKey": "minecraft-server-plugin",
"bundleVersion": "1.0.0",
"bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
"permissions": [
"server.read",
"server.lifecycle"
@@ -121,6 +124,9 @@
"key": "remote",
"title": "MC 远程",
"path": "/remote",
"bundleKey": "minecraft-server-plugin",
"bundleVersion": "1.0.0",
"bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
"permissions": [
"server.remote.access",
"server.files.read",
@@ -137,6 +143,9 @@
"key": "rcon",
"title": "MC RCON",
"path": "/rcon",
"bundleKey": "minecraft-server-plugin",
"bundleVersion": "1.0.0",
"bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
"permissions": [
"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 }; }
+14 -161
View File
@@ -46,10 +46,7 @@
"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",
"client-manager.deploy",
"client-manager.control",
"client-manager.update",
@@ -70,16 +67,8 @@
"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"
"remote.run.logs.transfer"
],
"databaseEngines": [
"mysql",
"sqlite"
],
"rcon": true,
"logTransfer": true
},
"bridge": {
@@ -100,6 +89,8 @@
},
"gameClientBridge": {
"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",
"title": "Send SCUM announcement",
@@ -199,6 +190,13 @@
"keepForSeconds": 604800,
"maxRecords": 1000
},
{
"type": "semantic.events",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/semantic-events.snapshot.schema.json",
"keepForSeconds": 604800,
"maxRecords": 1000
},
{
"type": "online.sessions",
"schemaVersion": "1",
@@ -242,68 +240,6 @@
"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,
"maxCommands": 1000,
"pages": [
@@ -318,14 +254,7 @@
"restart.prepare",
"maintenance.prepare"
],
"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"
]
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]
}
],
"companion": {
@@ -374,7 +303,7 @@
"dependencyPolicy": "required",
"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": {
"defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
@@ -419,8 +348,7 @@
"process.restart",
"process.status",
"remote.run.process.start",
"remote.run.process.stop",
"remote.run.rcon.command"
"remote.run.process.stop"
],
"actionRefs": {
"install": "actions/install.json",
@@ -430,33 +358,12 @@
"status": "actions/status.json"
},
"transportKeys": [
"server-files",
"sqlite-db",
"mysql-db",
"rcon"
"server-files"
],
"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",
@@ -759,38 +666,6 @@
"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": [
@@ -858,28 +733,6 @@
"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}}}
@@ -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",
"items": {
"type": "object",
"required": ["key", "title", "path"],
"required": ["key", "title", "path", "bundleKey", "bundleVersion", "bundleIntegritySha256"],
"additionalProperties": false,
"properties": {
"key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
"title": { "type": "string", "minLength": 1, "maxLength": 40 },
"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 },
"bridgeActions": { "type": "array", "items": { "$ref": "#/$defs/bridgeAction" }, "uniqueItems": true }
}
+3
View File
@@ -615,6 +615,9 @@ export interface GamePluginPage {
key: string;
title: string;
path: string;
bundleKey: string;
bundleVersion: string;
bundleIntegritySha256: `sha256:${string}`;
permissions?: PluginPermission[];
bridgeActions?: PluginBridgeAction[];
}
+11 -111
View File
@@ -199,68 +199,18 @@ describe("plugin manifest validation", () => {
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
});
it("accepts a pinned ready SCUM UE4SS DLL release and lifecycle reference", () => {
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", () => {
it("removes direct RCON, database, and DLL extension declarations", () => {
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
runtimeProfiles?: {
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[]; dllExtensionRefs?: string[] }>;
dllExtensions?: Array<{ key: string; releaseState: string }>;
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>;
transportProfiles?: Array<{ kind?: string }>;
};
};
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).toContain("remote.run.rcon.command");
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);
expect(local?.capabilities).not.toContain("remote.run.rcon.command");
expect(local?.transportKeys).not.toContain("rcon");
expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" || profile.kind === "mysql" || profile.kind === "rcon")).toBe(false);
});
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));
});
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 manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[];
@@ -612,62 +562,12 @@ describe("plugin manifest validation", () => {
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: 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 operationsPluginPage = manifest.pages.find((page) => page.key === "files-config");
expect(operationsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(expectedKeys));
expect(operationsPluginPage?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.remote.access"]));
expect(operationsPluginPage?.bridgeActions).toContain("remote.access.request");
expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]);
expect(operationsPage?.queryTemplateKeys ?? []).toEqual([]);
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", () => {