diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/ownership-audit.md b/openspec/changes/move-scum-feature-ownership-to-plugin/ownership-audit.md new file mode 100644 index 0000000..3882968 --- /dev/null +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/ownership-audit.md @@ -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. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index 7e300c6..3b737ec 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -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. diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 2eb0e76..b7f4517 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -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 { diff --git a/platform/dto/resources.go b/platform/dto/resources.go index fbabea5..ac16bc5 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -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), } diff --git a/platform/validator/resources.go b/platform/validator/resources.go index d8e3973..3982f25 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -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 diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 19f49ca..d9c8b2f 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -219,6 +219,9 @@ export interface GamePluginPageResponse { key: string; title: string; path: string; + bundleKey?: string; + bundleVersion?: string; + bundleIntegritySha256?: string; permissions: string[]; bridgeActions?: string[]; } diff --git a/platform_web/contracts/pluginBridge.ts b/platform_web/contracts/pluginBridge.ts index 802aaa5..c0d724f 100644 --- a/platform_web/contracts/pluginBridge.ts +++ b/platform_web/contracts/pluginBridge.ts @@ -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) })), diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 612f921..ef9080e 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -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(); 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(); - expect(html).toContain("SCUM 运维声明不可用"); - expect(html).toContain("缺少服务器实例上下文"); + expect(html).toContain("未绑定服务器"); + expect(html).toContain("正在校验并加载插件页面 bundle"); }); }); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 3aa2b02..7823906 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -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(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" }); + const [bundle, setBundle] = useState; 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 ; } @@ -56,7 +71,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin return void load()} />; } - const page = state.plugin.pages.find((candidate) => candidate.key === routeKey); + const page = declaredPage; if (!page) { return ; } @@ -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 (
- {scumResolution && !scumResolution.available && } - {scumResolution?.available && ( -
- 命令目录 {scumResolution.contract.commands.length} 项,均来自插件 operations 页面声明 - 快照目录 {scumResolution.contract.snapshots.length} 项,按插件 schemaVersion 投影 - 查询模板 {scumResolution.contract.queryTemplates.length} 项,仅保留声明式 SQLite 模板引用 -
- )} - {!scumResolution && ( - - )} +
Bundle {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}完整性 {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}Companion {availability.available ? "可用" : "不可用"}
- {scumResolution?.available && state.plugin.fileWorkspace && } - {scumResolution?.available && } - {scumResolution?.available && } + {bundleError && } + {!bundle && !bundleError && } + {bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, availability })} ); } diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 851783d..08fa3b5 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -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 })), diff --git a/platform_web/utils/pluginPageBundles.ts b/platform_web/utils/pluginPageBundles.ts new file mode 100644 index 0000000..0f9ccfd --- /dev/null +++ b/platform_web/utils/pluginPageBundles.ts @@ -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; +} + +const pageBundles = import.meta.glob("../../plugins/examples/*/page-bundle/index.ts"); + +export async function loadPluginPageBundle(page: PluginPageContract): Promise> { + 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"); } diff --git a/plugins/examples/dev-game-plugin/manifest.json b/plugins/examples/dev-game-plugin/manifest.json index bf9f13e..5ca6b10 100644 --- a/plugins/examples/dev-game-plugin/manifest.json +++ b/plugins/examples/dev-game-plugin/manifest.json @@ -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"] } diff --git a/plugins/examples/minecraft-server-plugin/manifest.json b/plugins/examples/minecraft-server-plugin/manifest.json index f1acb28..0a0f1ee 100644 --- a/plugins/examples/minecraft-server-plugin/manifest.json +++ b/plugins/examples/minecraft-server-plugin/manifest.json @@ -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" ], diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go new file mode 100644 index 0000000..d86471b --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/dispatcher.go @@ -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): } } } diff --git a/plugins/examples/scum-server-plugin/companion/events.go b/plugins/examples/scum-server-plugin/companion/events.go new file mode 100644 index 0000000..2bb99f9 --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/events.go @@ -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}) } diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts new file mode 100644 index 0000000..7ff7bcf --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/api.ts @@ -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 { const value = await host.getCompanionAvailability(serverInstanceId); return { feature, available: value.available, reason: value.reason }; } diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts new file mode 100644 index 0000000..c219891 --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -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> = { provenance: "plugin" | "transitional-read-only"; payload: T; recordedAt: string }; +export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed"; summary: string; audit?: Record }; diff --git a/plugins/examples/scum-server-plugin/features/migration.ts b/plugins/examples/scum-server-plugin/features/migration.ts new file mode 100644 index 0000000..6aa110c --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/migration.ts @@ -0,0 +1,3 @@ +import type { SCUMMigrationRecord } from "./contracts"; + +export function transitionalReadOnly>(payload: T, recordedAt: string): SCUMMigrationRecord { return { provenance: "transitional-read-only", payload, recordedAt }; } diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts new file mode 100644 index 0000000..2171754 --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -0,0 +1,3 @@ +const supportedFields: Record = { "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 }; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 6096779..9902abb 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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 - } ] } } diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts new file mode 100644 index 0000000..bc8551d --- /dev/null +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -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" ? "修改后需重启" : "无需重启"}`))))) + ); +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/config-patch.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/config-patch.payload.schema.json new file mode 100644 index 0000000..1bdc42b --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/config-patch.payload.schema.json @@ -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}}}}}} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/config-patch.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/config-patch.result.schema.json new file mode 100644 index 0000000..7a9d735 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/config-patch.result.schema.json @@ -0,0 +1 @@ +{"type":"object","additionalProperties":false,"required":["result"],"properties":{"result":{"type":"string","enum":["applied","unsupported","failed"],"maxLength":16},"revision":{"type":"string","maxLength":80}}} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/config-read.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/config-read.payload.schema.json new file mode 100644 index 0000000..0876423 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/config-read.payload.schema.json @@ -0,0 +1 @@ +{"type":"object","additionalProperties":false,"properties":{}} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/config-read.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/config-read.result.schema.json new file mode 100644 index 0000000..6a6f12f --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/config-read.result.schema.json @@ -0,0 +1 @@ +{"type":"object","additionalProperties":false,"required":["result"],"properties":{"result":{"type":"string","enum":["available","unsupported","failed"],"maxLength":16}}} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/semantic-events.snapshot.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/semantic-events.snapshot.schema.json new file mode 100644 index 0000000..34fcb99 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/semantic-events.snapshot.schema.json @@ -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}}}}}} diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 56dc941..afdc048 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -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 } } diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index ba9638e..e3e40b4 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -615,6 +615,9 @@ export interface GamePluginPage { key: string; title: string; path: string; + bundleKey: string; + bundleVersion: string; + bundleIntegritySha256: `sha256:${string}`; permissions?: PluginPermission[]; bridgeActions?: PluginBridgeAction[]; } diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 6279fc9..63192e4 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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; - 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; - 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", () => {