Remove pre-1.0 audit and protected request scaffolding

This commit is contained in:
npc0-hue
2026-08-20 23:42:02 +08:00
parent 40b35b05c7
commit a7e2e4c6c0
130 changed files with 526 additions and 3767 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ Runtime profiles are declarative contracts, not executable scripts. A profile ca
Client-manager profiles declare repository URL, revision policy, semantic version, supported target OS/architecture pairs, a fixed build adapter, config template keys, produced artifacts, and an optional complete lifecycle contract. The lifecycle contract names a safe relative executable, fixed arguments, required Run capabilities, bounded start/stop/restart/status/update/rollback/uninstall actions, heartbeat/process health thresholds, component capabilities, compatibility bounds, and a manual staged-update policy. It cannot contain arbitrary shell, absolute/traversing paths, direct sockets, endpoints, raw credentials, or secret/session values.
Platform performs target and lifecycle validation, creates a real build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. For profiles with a complete lifecycle contract, the artifact can then be deployed by a typed Run job into a controlled workspace, registered using a separate short-lived component session, health-checked, controlled, updated/rolled back, revoked, and safely uninstalled. The Run key and client-manager key/session remain separate; resetting the client-manager key revokes old packages and sessions and requires a current-generation rebuild and redeploy.
Platform performs target and lifecycle validation, creates a real build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. For profiles with a complete lifecycle contract, the artifact can then be deployed by a typed Run job into a managed workspace, registered using a separate short-lived component session, health-checked, started/stopped, updated/rolled back, revoked, and safely uninstalled. The Run key and client-manager key/session remain separate; resetting the client-manager key revokes old packages and sessions and requires a current-generation rebuild and redeploy.
Plugin pages may request these operations only through bridge helpers:
@@ -51,7 +51,7 @@ Plugin pages may request these operations only through bridge helpers:
- `createDependencyActionRequest`: check or install declared dependency probes/plans.
- `createLogBackfillRequest`: request historical log cursors for declared sources.
- `createClientManagerRequest`: generate/download/reset or request safe status/deploy/control/update/rollback/revoke/retry/uninstall operations for declared client-manager packages.
- `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform governance.
- `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform operations.
- `parseClientManagerLifecycleStatus`: whitelist the plugin-visible status, version, health, artifact/job IDs, deployment generation, and allowed actions without component secrets or machine details.
Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets.
@@ -19,7 +19,7 @@ function renderOverviewPage(e: ReactLike["createElement"], input: any) {
}
function renderConfigPage(e: ReactLike["createElement"], input: any) {
return renderPanel(e, "配置工作台", "配置入口由插件页面声明,平台只提供受控上下文。", input, [
return renderPanel(e, "配置工作台", "配置入口由插件页面声明,平台只提供运行上下文。", input, [
["文件权限", (input.context?.permissions ?? []).filter((value: string) => value.includes("files")).join(" / ") || "未声明"],
["AI 能力", (input.context?.permissions ?? []).includes("ai.invoke") ? "可请求平台 AI" : "未声明"],
["写入策略", "平台审查后派发"]
@@ -14,7 +14,7 @@ function renderFiles(e: ReactLike["createElement"], input: any, workspace: any)
const files = workspace?.files ?? [];
return e("section", { className: "console-panel", "aria-label": "Minecraft 文件管理" },
e("div", { className: "panel-header" },
e("div", null, e("h2", null, "文件管理"), e("p", { className: "provider-id" }, "server.properties、白名单、OP 列表和日志由 Minecraft 插件声明。")),
e("div", null, e("h2", null, "文件管理"), e("p", { className: "provider-id" }, "server.properties、whitelist.json、OP 列表和日志由 Minecraft 插件声明。")),
e("span", { className: "page-status" }, input.context?.serverInstanceId ? "已绑定服务器" : "未绑定服务器")
),
e("div", { className: "console-row-list" },
@@ -14,4 +14,4 @@ access.
Semantic events come from bounded Run stdout/stderr console records. Unknown
records create diagnostics and never produce fabricated events. DSNs, rows,
connections, and credentials do not leave Run; request text is protected and
redacted from browser and audit projections.
redacted from browser and platform projections.
@@ -13,7 +13,7 @@ import (
var errAdapterUnsupported = errors.New("runtime capability is unavailable")
// AuthorizedConfigPort is supplied through the platform-authorized Run channel.
// It exposes logical, allowlisted configuration values only; it never exposes a
// It exposes logical, declared configuration values only; it never exposes a
// path, DSN, credential, arbitrary command, or database handle.
type AuthorizedConfigPort interface {
ReadConfig(context.Context) (map[string]string, error)
@@ -25,7 +25,7 @@ type ConfigFieldPatch struct {
}
// AuthorizedGameDataPort is a typed, Run-owned read/patch boundary. Implementations
// must probe their local schema, use field allowlists and safe windows, and return
// must probe their local schema, use declared fields and safe windows, and return
// bounded snapshots rather than rows or connection details.
type AuthorizedGameDataPort interface {
ReadPlayerState(context.Context, string, []string) (PlayerStateSnapshot, error)
@@ -109,11 +109,11 @@ type UE4SSNotificationPort interface {
}
type UE4SSNotificationReceipt struct{ Accepted bool }
type ue4SSPlayerNotification struct {
ServerID string
RecipientSteamID string
Message string
chatType int
protectedAuditCommand string
ServerID string
RecipientSteamID string
Message string
chatType int
localCommandPreview string
}
type UE4SSVehicleSpawnPort interface {
SpawnVehicle(context.Context, ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error)
@@ -128,9 +128,9 @@ const (
type UE4SSVehicleSpawnReceipt struct{ Outcome UE4SSVehicleSpawnOutcome }
type ue4SSVehicleSpawn struct {
ServerID string
VehicleCode string
protectedAuditCommand string
ServerID string
VehicleCode string
localCommandPreview string
}
// RuntimeAdapter is bound to one server. Availability is discovered from its
@@ -312,13 +312,13 @@ func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayer
if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) {
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed notification")
}
return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, protectedAuditCommand: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil
return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, localCommandPreview: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil
}
func newUE4SSVehicleSpawn(serverID, vehicleCode string) (ue4SSVehicleSpawn, error) {
if strings.TrimSpace(serverID) == "" || !supportedVehicleSpawnCode(vehicleCode) {
return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed vehicle spawn")
}
return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, protectedAuditCommand: "#spawnvehicle " + vehicleCode}, nil
return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, localCommandPreview: "#spawnvehicle " + vehicleCode}, nil
}
func steamID64(value string) bool {
if len(value) != 17 {
@@ -105,13 +105,13 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
t.Fatalf("dispatch supported adapters: %v", err)
}
if len(port.notifications) != 1 || port.notifications[0].ServerID != "server-1" || port.notifications[0].protectedAuditCommand == "" {
if len(port.notifications) != 1 || port.notifications[0].ServerID != "server-1" || port.notifications[0].localCommandPreview == "" {
t.Fatalf("notification did not remain server-bound and idempotent: %+v", port.notifications)
}
if len(port.patches) != 1 || gateway.completed["config-read"][0].Payload["fields"].(map[string]string)["ServerName"] != "Moonlight" || gateway.completed["config-patch"][0].Payload["appliedFields"].(map[string]string)["ServerName"] != "Moonlight" || gateway.completed["notify"][0].Payload["accepted"] != true {
t.Fatalf("supported adapters did not return their bounded successful results: patches=%+v completed=%+v", port.patches, gateway.completed)
}
if len(port.spawns) != 3 || port.spawns[0].protectedAuditCommand != "#spawnvehicle BPC_Laika_C" || port.spawns[1].protectedAuditCommand != "#spawnvehicle BPC_WolfsWagen_C" || port.spawns[2].protectedAuditCommand != "#spawnvehicle BPC_Laika_C" {
if len(port.spawns) != 3 || port.spawns[0].localCommandPreview != "#spawnvehicle BPC_Laika_C" || port.spawns[1].localCommandPreview != "#spawnvehicle BPC_WolfsWagen_C" || port.spawns[2].localCommandPreview != "#spawnvehicle BPC_Laika_C" {
t.Fatalf("vehicle adapter did not use only fixed private templates: %+v", port.spawns)
}
if gateway.completed["spawn-success"][0].Payload["outcome"] != "succeeded" || gateway.completed["spawn-failed"][0].Payload["outcome"] != "failed" || gateway.completed["spawn-unknown"][0].Payload["outcome"] != "unknown" {
@@ -173,8 +173,8 @@ func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *test
t.Fatalf("%s did not redact failed typed-port output: %+v", id, result)
}
}
if len(port.notifications) != 1 || port.notifications[0].protectedAuditCommand == "" {
t.Fatalf("notification fixture did not receive one protected typed request: %+v", port.notifications)
if len(port.notifications) != 1 || port.notifications[0].localCommandPreview == "" {
t.Fatalf("notification fixture did not receive one typed request: %+v", port.notifications)
}
}
@@ -197,10 +197,10 @@ func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) {
t.Fatalf("typed notification was not delivered: result=%+v err=%v deliveries=%+v", result, err, port.deliveries)
}
delivery := port.deliveries[0]
if delivery.ServerID != "server-1" || delivery.chatType != fixedNotificationType || delivery.protectedAuditCommand != "SendChat 4 \"Moon \\\"gift\\\"\" 76561198000000001" {
if delivery.ServerID != "server-1" || delivery.chatType != fixedNotificationType || delivery.localCommandPreview != "SendChat 4 \"Moon \\\"gift\\\"\" 76561198000000001" {
t.Fatalf("notification did not use the fixed UE4SS contract: %+v", delivery)
}
if result["message"] == delivery.protectedAuditCommand || result["command"] != nil || result["rcon"] != nil {
if result["message"] == delivery.localCommandPreview || result["command"] != nil || result["rcon"] != nil {
t.Fatalf("notification leaked protected transport details: %+v", result)
}
}
@@ -230,7 +230,7 @@ func TestNotificationFailureIsCachedWithoutInvokingRewardDelivery(t *testing.T)
}
}
func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivateAuditOnly(t *testing.T) {
func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivatePreviewOnly(t *testing.T) {
port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnAccepted}}
adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port}
result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_Laika_C"})
@@ -238,10 +238,10 @@ func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivateAuditOnly(t *testing.T)
t.Fatalf("fixed vehicle spawn was not delivered: result=%+v err=%v requests=%+v", result, err, port.requests)
}
request := port.requests[0]
if request.ServerID != "server-1" || request.VehicleCode != "BPC_Laika_C" || request.protectedAuditCommand != "#spawnvehicle BPC_Laika_C" {
if request.ServerID != "server-1" || request.VehicleCode != "BPC_Laika_C" || request.localCommandPreview != "#spawnvehicle BPC_Laika_C" {
t.Fatalf("vehicle spawn did not use the fixed template: %+v", request)
}
if result["command"] != nil || result["rcon"] != nil || result["audit"] != nil || result["outcome"] == request.protectedAuditCommand {
if result["command"] != nil || result["rcon"] != nil || result["outcome"] == request.localCommandPreview {
t.Fatalf("vehicle spawn leaked protected transport details: %+v", result)
}
}
@@ -112,7 +112,7 @@ func TestRunOneShotSmokeLeavesUnsupportedCommandUnackedAndUnexecuted(t *testing.
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil
case 2:
step++
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "unsupported-1", ProfileKey: ProfileKey, CommandType: "announcement.send", Payload: map[string]any{"message": "must not execute"}, FencingToken: 18, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "unsupported-1", ProfileKey: ProfileKey, CommandType: "diagnostic.unsupported", Payload: map[string]any{"message": "must not execute"}, FencingToken: 18, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil
default:
return nil, fmt.Errorf("unsupported command triggered transport: %s", request.URL.Path)
}
@@ -24,5 +24,5 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatu
};
}
function commandResult(result: { status: string; result?: Record<string, string>; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入受控队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "受控操作未被接受。" }; }
function commandResult(result: { status: string; result?: Record<string, string>; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入任务队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "操作未被接受。" }; }
function decode<T>(result: Record<string, string> | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } }
@@ -6,7 +6,7 @@ export type SCUMMigrationProvenance = "plugin" | "transitional-read-only";
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: SCUMMigrationProvenance; readOnly: boolean; payload: T; recordedAt: string; sourceRecordId?: string };
export type SCUMFeatureMigrationAuthority = { serverInstanceId: string; feature: SCUMFeatureKey; authority: "plugin" | "transitional-read-only"; reason?: string };
export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-read-only"; readOnlyHistory: true; pluginWritesEnabled: boolean; reason?: string };
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record<string, unknown> };
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string };
export type SCUMVehicleSpawn = { vehicleCode: string };
export type SCUMVehicleSpawnOption = { code: string; label: string };
export type SCUMLogicalDirectory = { key: string; label: string; scope: "config" | "logs" };
@@ -23,7 +23,7 @@ export function migratePlayerRecord(record: Record<string, unknown>): SCUMMigrat
}
export function migrateConfigurationRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMConfigRead> | null {
const fields = allowlistedConfigFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!fields || !observedAt) return null;
const fields = declaredConfigFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!fields || !observedAt) return null;
return transitionalReadOnly({ fields, observedAt }, observedAt, text(record.id));
}
@@ -63,7 +63,7 @@ function migratePoint(value: unknown, defaultSubjectId: string, defaultSubjectTy
function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; }
function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; }
function allowlistedConfigFields(value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function declaredConfigFields(value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; }
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
function trajectorySubjectType(record: Record<string, unknown>): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; }
@@ -1,6 +1,6 @@
import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js";
// These are safe fallback allowlists. A Companion schema probe may narrow them
// These are safe fallback plugin catalogs. A Companion schema probe may narrow them
// per server, but a game version never enables or disables a feature.
export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "server-name", fileKey: "scum-server-settings", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" },
@@ -13,6 +13,6 @@ export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }];
export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); }
export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; }
export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在受控目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; }
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 不在运行时字段白名单中`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; }
export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在受控目录中声明。"; return null; }
export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在插件目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; }
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 未在插件运行时目录中声明`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; }
export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在插件目录中声明。"; return null; }
@@ -75,7 +75,7 @@
"remote.run.process.stop",
"remote.run.logs.transfer",
"remote.run.protected.sql",
"remote.run.protected.rcon",
"remote.run.rcon.command",
"remote.run.program.command",
"client-manager.deploy",
"client-manager.control",
@@ -100,7 +100,7 @@
"remote.run.process.stop",
"remote.run.logs.transfer",
"remote.run.protected.sql",
"remote.run.protected.rcon",
"remote.run.rcon.command",
"remote.run.program.command"
],
"databaseEngines": [
@@ -123,23 +123,6 @@
},
"gameClientBridge": {
"commands": [
{
"type": "announcement.send",
"title": "Send SCUM announcement",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json",
"resultSchemaRef": "schemas/bridge/announcement.result.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 4096,
"protectedRequest": {
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"textField": "requestText",
"maxTextBytes": 2048
}
},
{
"type": "companion.diagnostics",
"title": "Collect companion diagnostics",
@@ -497,13 +480,6 @@
"captureMappings": { "steamId": "steamId", "displayName": "displayName" },
"fixedValues": { "eventType": "login", "source": "process.stdout" },
"observedAtField": "observedAt"
},
"announcement": {
"profileKey": "scum-client-manager",
"commandType": "announcement.send",
"textField": "requestText",
"newTextTemplate": "#announce 欢迎新玩家 {{displayName}} 加入服务器!",
"returningTextTemplate": "#announce 欢迎 {{displayName}} 继续游戏!"
}
}
}
@@ -522,91 +498,66 @@
"key": "player.fame.set",
"title": "Set SCUM player fame through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-fame-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-fame-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
"maxPayloadBytes": 2048
},
{
"key": "player.currency.normal.set",
"title": "Set SCUM normal currency through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
"maxPayloadBytes": 2048
},
{
"key": "player.currency.gold.set",
"title": "Set SCUM gold currency through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
"maxPayloadBytes": 2048
},
{
"key": "player.notify",
"title": "Notify SCUM player through RCON chat",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-notify.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-notify.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
"maxPayloadBytes": 2048
},
{
"key": "reward.deliver",
"title": "Deliver approved SCUM reward through typed command workflow",
"title": "Deliver SCUM reward through typed command workflow",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json",
"resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/reward-deliver.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 4096,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
"maxPayloadBytes": 4096
},
{
"key": "player.attribute.855.set",
@@ -1072,7 +1023,8 @@
"process.restart",
"process.status",
"remote.run.process.start",
"remote.run.process.stop"
"remote.run.process.stop",
"remote.run.rcon.command"
],
"actionRefs": {
"install": "actions/install.json",
@@ -1082,7 +1034,11 @@
"status": "actions/status.json"
},
"transportKeys": [
"server-files"
"server-files",
"scum-management"
],
"dllExtensionRefs": [
"scum-simple-rcon"
],
"platforms": [
"windows"
@@ -1412,7 +1368,7 @@
"kind": "rcon",
"targetKey": "scum-management",
"capabilities": [
"remote.run.protected.rcon"
"remote.run.rcon.command"
]
},
{
@@ -1424,6 +1380,32 @@
]
}
],
"dllExtensions": [
{
"key": "scum-simple-rcon",
"displayName": "SCUM Simple RCON",
"kind": "ue4ss-dll",
"activation": "server-start",
"version": "0.1.0",
"releaseState": "ready",
"releaseUrl": "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll",
"checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sizeBytes": 1024,
"targetKey": "ue4ss/scum-simple-rcon",
"modKey": "scum_simple_rcon",
"dllRef": "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
"scumExecutableChecksum": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"ue4ssAbi": "ue4ss-3.0",
"supportedTargets": [
{
"os": "windows",
"arch": "amd64"
}
],
"updateOnStart": true,
"rconPort": 27015
}
],
"clientManagers": [
{
"key": "scum-client-manager",
@@ -1,14 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMAnnouncementPayload",
"type": "object",
"additionalProperties": false,
"required": ["requestText"],
"properties": {
"requestText": {
"type": "string",
"minLength": 1,
"maxLength": 2048
}
}
}
@@ -1,17 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMAnnouncementResult",
"type": "object",
"additionalProperties": false,
"required": ["accepted"],
"properties": {
"accepted": {
"type": "boolean"
},
"messageId": {
"type": "string",
"minLength": 1,
"maxLength": 120
}
}
}
@@ -317,20 +317,7 @@
"payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
"maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 },
"protectedRequest": { "$ref": "#/$defs/gameClientBridgeProtectedRequest" }
}
},
"gameClientBridgeProtectedRequest": {
"type": "object",
"required": ["kind", "transportKey", "targetKey", "textField", "maxTextBytes"],
"additionalProperties": false,
"properties": {
"kind": { "enum": ["sql", "rcon", "program"] },
"transportKey": { "$ref": "#/$defs/logicalKey" },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"textField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
"maxTextBytes": { "type": "integer", "minimum": 1, "maximum": 16384 }
"maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }
}
},
"gameClientBridgeSnapshot": {
@@ -412,25 +399,12 @@
},
"gameClientBridgeLogProjectionPresence": {
"type": "object",
"required": ["timestampField", "activeWindowSeconds", "announcement"],
"required": ["timestampField", "activeWindowSeconds"],
"additionalProperties": false,
"properties": {
"timestampField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
"activeWindowSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 },
"activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" },
"announcement": { "$ref": "#/$defs/gameClientBridgeLogProjectionAnnouncement" }
}
},
"gameClientBridgeLogProjectionAnnouncement": {
"type": "object",
"required": ["profileKey", "commandType", "textField", "newTextTemplate", "returningTextTemplate"],
"additionalProperties": false,
"properties": {
"profileKey": { "$ref": "#/$defs/logicalKey" },
"commandType": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
"textField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
"newTextTemplate": { "type": "string", "minLength": 1, "maxLength": 4096 },
"returningTextTemplate": { "type": "string", "minLength": 1, "maxLength": 4096 }
"activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" }
}
},
"gameClientBridgeDataPack": {
@@ -480,7 +454,7 @@
"key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
"permission": { "$ref": "#/$defs/pluginPermission" },
"approvalLevel": { "enum": ["operator", "platform-admin"] },
"approvalLevel": { "enum": ["none", "operator", "platform-admin"] },
"kind": { "enum": ["rcon", "sqlite-mutation"] },
"transportKey": { "$ref": "#/$defs/logicalKey" },
"targetKey": { "$ref": "#/$defs/logicalKey" },
@@ -552,7 +526,6 @@
"remote.run.logs.transfer",
"remote.run.rcon.command",
"remote.run.protected.sql",
"remote.run.protected.rcon",
"remote.run.program.command",
"client-manager.deploy",
"client-manager.control",
+6 -50
View File
@@ -675,8 +675,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) {
return [];
}
type ProtectedRequest = { kind?: string; transportKey?: string; targetKey?: string; textField?: string; maxTextBytes?: number };
type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string; protectedRequest?: ProtectedRequest };
type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string };
type BridgeQueryTemplate = {
key?: string;
permission?: string;
@@ -703,7 +702,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
timestampField?: string;
activeWindowSeconds?: number;
activityTarget?: BridgeLogProjectionTarget;
announcement?: { profileKey?: string; commandType?: string; textField?: string; newTextTemplate?: string; returningTextTemplate?: string };
};
};
type BridgeOperationSafety = { requiresApproval?: boolean; requiresOfflinePlayer?: boolean; requiresMaintenanceWindow?: boolean; requiresBeforeValue?: boolean; requiresConfirmation?: boolean; backupRequired?: boolean };
@@ -806,44 +804,10 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
for (const [index, command] of (bridge.commands ?? []).entries()) {
const location = `manifest.gameClientBridge.commands[${index}]`;
const type = command.type ?? "";
const unsafeTypeReason = command.protectedRequest ? undefined : unsafeGameClientBridgeCommandTypeReason(type);
const unsafeTypeReason = unsafeGameClientBridgeCommandTypeReason(type);
if (unsafeTypeReason) {
errors.push(`${location}.type: ${unsafeTypeReason}`);
}
if (!command.approvalLevel) {
errors.push(`${location}.approvalLevel: approval metadata is required`);
}
const protectedRequest = command.protectedRequest;
if (protectedRequest) {
if (!new Set(["sql", "rcon", "program"]).has(protectedRequest.kind ?? "")) {
errors.push(`${location}.protectedRequest.kind: must be sql, rcon, or program`);
}
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(protectedRequest.textField ?? "")) {
errors.push(`${location}.protectedRequest.textField: must be a safe bounded field name`);
}
if (!Number.isInteger(protectedRequest.maxTextBytes) || (protectedRequest.maxTextBytes ?? 0) < 1 || (protectedRequest.maxTextBytes ?? 0) > 16384) {
errors.push(`${location}.protectedRequest.maxTextBytes: must be between 1 and 16384`);
}
const transport = transportProfiles.find((candidate) => candidate.key === protectedRequest.transportKey);
if (!transport) {
errors.push(`${location}.protectedRequest.transportKey: must reference a declared runtime transport profile`);
} else {
if (!protectedRequest.targetKey || protectedRequest.targetKey !== transport.targetKey) {
errors.push(`${location}.protectedRequest.targetKey: must match the declared runtime transport target`);
}
const expectedCapability = { sql: "remote.run.protected.sql", rcon: "remote.run.protected.rcon", program: "remote.run.program.command" }[protectedRequest.kind ?? ""];
if (protectedRequest.kind === "sql" && transport.kind !== "mysql" && transport.kind !== "sqlite") {
errors.push(`${location}.protectedRequest.transportKey: sql requests require mysql or sqlite transport`);
}
if ((protectedRequest.kind === "rcon" && transport.kind !== "rcon") || (protectedRequest.kind === "program" && transport.kind !== "program")) {
errors.push(`${location}.protectedRequest.transportKey: transport kind does not match protected request kind`);
}
if (expectedCapability && !transport.capabilities?.includes(expectedCapability)) {
errors.push(`${location}.protectedRequest.transportKey: is missing required protected transport capability`);
}
}
}
for (const [field, ref] of [["payloadSchemaRef", command.payloadSchemaRef], ["resultSchemaRef", command.resultSchemaRef]] as const) {
if (ref && !isSafeRelativeJsonRef(ref)) {
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
@@ -966,14 +930,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
if (!fieldNamePattern.test(presence.timestampField ?? "") || !targetFields.has(presence.timestampField ?? "")) errors.push(`${location}.presence.timestampField: must reference a projected target field`);
if (!Number.isInteger(presence.activeWindowSeconds) || (presence.activeWindowSeconds ?? 0) < 1 || (presence.activeWindowSeconds ?? 0) > 31536000) errors.push(`${location}.presence.activeWindowSeconds: must be between 1 and 31536000`);
if (presence.activityTarget) errors.push(...validateProjectionTarget(`${location}.presence.activityTarget`, presence.activityTarget, captures));
const announcement = presence.announcement;
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === announcement?.profileKey && candidate.health?.requiredCapabilities?.includes("game-client.bridge"));
if (!manager) errors.push(`${location}.presence.announcement.profileKey: must reference a declared game-client bridge profile`);
const command = (bridge.commands ?? []).find((candidate) => candidate.type === announcement?.commandType);
if (!command) errors.push(`${location}.presence.announcement.commandType: must reference a declared command`);
if (!fieldNamePattern.test(announcement?.textField ?? "") || (command?.protectedRequest && command.protectedRequest.textField !== announcement?.textField)) errors.push(`${location}.presence.announcement.textField: must be safe and match the command protected request`);
if (!announcement?.newTextTemplate || announcement.newTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.newTextTemplate: must be a non-empty bounded template`);
if (!announcement?.returningTextTemplate || announcement.returningTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.returningTextTemplate: must be a non-empty bounded template`);
}
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
const location = `manifest.gameClientBridge.operationTemplates[${index}]`;
@@ -989,8 +945,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
if (!operationTemplate.permission || !declaredPermissions.has(operationTemplate.permission)) {
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
}
if (!new Set(["operator", "platform-admin"]).has(operationTemplate.approvalLevel ?? "")) {
errors.push(`${location}.approvalLevel: must require operator or platform-admin approval`);
if (!new Set(["none", "operator", "platform-admin"]).has(operationTemplate.approvalLevel ?? "")) {
errors.push(`${location}.approvalLevel: must be none, operator, or platform-admin`);
}
if (!new Set(["rcon", "sqlite-mutation"]).has(operationTemplate.kind ?? "")) {
errors.push(`${location}.kind: must be rcon or sqlite-mutation`);
@@ -1015,8 +971,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
errors.push(`${location}.targetKey: must match the declared runtime transport target`);
}
if (operationTemplate.kind === "rcon") {
if (transport.kind !== "rcon" || !transport.capabilities?.includes("remote.run.protected.rcon")) {
errors.push(`${location}.transportKey: rcon operations require remote.run.protected.rcon transport`);
if (transport.kind !== "rcon" || !transport.capabilities?.includes("remote.run.rcon.command")) {
errors.push(`${location}.transportKey: rcon operations require remote.run.rcon.command transport`);
}
if (operationTemplate.maxRowsAffected !== undefined) {
errors.push(`${location}.maxRowsAffected: only sqlite-mutation operations may declare affected row bounds`);
+2 -25
View File
@@ -47,7 +47,6 @@ export type RunCapability =
| "remote.run.logs.transfer"
| "remote.run.rcon.command"
| "remote.run.protected.sql"
| "remote.run.protected.rcon"
| "remote.run.program.command"
| "client-manager.deploy"
| "client-manager.control"
@@ -223,26 +222,15 @@ export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admi
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "unknown" | "cancelled" | "expired";
export type GameClientBridgeProtectedRequestKind = "sql" | "rcon" | "program";
export interface GameClientBridgeProtectedRequestDeclaration {
kind: GameClientBridgeProtectedRequestKind;
transportKey: string;
targetKey: string;
textField: string;
maxTextBytes: number;
}
export interface GameClientBridgeCommandDeclaration {
type: string;
title: string;
permission: PluginPermission;
approvalLevel: GameClientBridgeApprovalLevel;
payloadSchemaRef: string;
resultSchemaRef?: string;
timeoutSeconds: number;
resultSchemaRef?: string;
timeoutSeconds: number;
maxPayloadBytes: number;
protectedRequest?: GameClientBridgeProtectedRequestDeclaration;
}
export interface GameClientBridgeSnapshotDeclaration {
@@ -288,19 +276,10 @@ export interface GameClientBridgeLogProjectionTargetDeclaration {
observedAtField?: string;
}
export interface GameClientBridgeLogProjectionAnnouncementDeclaration {
profileKey: string;
commandType: string;
textField: string;
newTextTemplate: string;
returningTextTemplate: string;
}
export interface GameClientBridgeLogProjectionPresenceDeclaration {
timestampField: string;
activeWindowSeconds: number;
activityTarget?: GameClientBridgeLogProjectionTargetDeclaration;
announcement: GameClientBridgeLogProjectionAnnouncementDeclaration;
}
export interface GameClientBridgeLogProjectionDeclaration {
@@ -441,7 +420,6 @@ export interface GameClientBridgeCommand {
state: GameClientBridgeCommandState;
approvalState: GameClientBridgeApprovalState;
result?: GameClientBridgeCommandResult;
auditReferences?: string[];
expiresAt: string;
createdAt: string;
updatedAt: string;
@@ -459,7 +437,6 @@ export interface GameClientBridgeSnapshot<TPayload extends Record<string, unknow
sequence: number;
observedAt: string;
payload: TPayload;
auditReferences?: string[];
createdAt: string;
expiresAt: string;
}
+40 -54
View File
@@ -28,7 +28,6 @@ import {
type GameClientBridgeQueryTemplateDeclaration,
type GameClientBridgeLogProjectionDeclaration,
type GameClientBridgeOperationTemplateDeclaration,
type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration,
type GamePluginManifest,
type RuntimeLogEventDeclaration,
@@ -95,13 +94,13 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
const manifestPath = path.join(fixtureDir, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableBridgeManifest;
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.protected.rcon", "remote.run.protected.sql"];
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.rcon.command", "remote.run.protected.sql"];
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read", "server.game-client.maintenance"];
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query", "remote.run.protected.rcon", "remote.run.protected.sql"], databaseEngines: ["sqlite"] };
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query", "remote.run.rcon.command", "remote.run.protected.sql"], databaseEngines: ["sqlite"] };
manifest.runtimeProfiles = {
transportProfiles: [
{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] },
{ key: "scum-rcon", kind: "rcon", targetKey: "scum-rcon", capabilities: ["remote.run.protected.rcon"] },
{ key: "scum-rcon", kind: "rcon", targetKey: "scum-rcon", capabilities: ["remote.run.rcon.command"] },
{ key: "scum-mutation-db", kind: "sqlite", targetKey: "scum-mutation-db", capabilities: ["remote.run.protected.sql"] }
]
};
@@ -111,19 +110,19 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"];
}
manifest.gameClientBridge = {
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
queryTemplates: [{ key: "player.by-id", title: "Find player by ID", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/player-by-id.result.schema.json", maxRows: 1, timeoutSeconds: 10 }],
operationTemplates: [
{ key: "player.fame.set", title: "Set player fame", permission: "server.game-client.command", approvalLevel: "operator", kind: "rcon", transportKey: "scum-rcon", targetKey: "scum-rcon", payloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-fame-set.result.schema.json", confirmationSchemaRef: "schemas/bridge/player-fame-set.confirmation.schema.json", timeoutSeconds: 60, maxPayloadBytes: 2048, safety: { requiresApproval: true, requiresConfirmation: true } },
{ key: "player.fame.set", title: "Set player fame", permission: "server.game-client.command", approvalLevel: "none", kind: "rcon", transportKey: "scum-rcon", targetKey: "scum-rcon", payloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-fame-set.result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 2048 },
{ key: "player.attribute.855.set", title: "Set player attribute 855", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", kind: "sqlite-mutation", transportKey: "scum-mutation-db", targetKey: "scum-mutation-db", payloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", confirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", timeoutSeconds: 120, maxPayloadBytes: 4096, maxRowsAffected: 1, mutation: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.by-id", allowedValueType: "integer", minValue: 0, maxValue: 100000 }, safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true } }
],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"], operationKeys: ["player.fame.set", "player.attribute.855.set"] }]
pages: [{ pageKey: "overview", commandTypes: ["diagnostic.ping"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"], operationKeys: ["player.fame.set", "player.attribute.855.set"] }]
};
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 100, items: bridgeObjectSchema({ id: { type: "string", minLength: 1, maxLength: 80 } }, ["id"]) } }, ["players"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 1, items: bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]) } }, ["players"]));
@@ -191,13 +190,10 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
});
it("removes raw SQL command surfaces and keeps announcements as a typed protected RCON request", () => {
it("removes raw SQL command surfaces", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; protectedRequest?: { kind: string; transportKey: string; targetKey: string; textField: string; maxTextBytes: number } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
expect(commands).toEqual([expect.objectContaining({ type: "announcement.send", protectedRequest: { kind: "rcon", transportKey: "scum-management", targetKey: "scum-management", textField: "requestText", maxTextBytes: 2048 } })]);
const announcementPayload = JSON.parse(fs.readFileSync(path.join(pluginDir, commands[0].payloadSchemaRef), "utf8"));
expect(announcementPayload).toMatchObject({ required: ["requestText"], properties: { requestText: { type: "string", minLength: 1, maxLength: 2048 } } });
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
expect(manifest.gameClientBridge.commands.some((command) => command.type === "diagnostic.ping")).toBe(false);
expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"]));
expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"]));
expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver", "player.attribute.855.set"]));
@@ -205,13 +201,13 @@ describe("plugin manifest validation", () => {
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
});
it("declares BattlEye login projection, presence deduplication, and plugin-owned welcome messages", () => {
it("declares BattlEye login projection and presence deduplication", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any;
const projection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.battleye.login");
expect(projection).toMatchObject({
streamKeys: ["scum.console.stdout"], correlationFields: ["slot"], maxInterveningLines: 8,
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", displayName: "displayName", slot: "slot" }, fixedValues: { online: "true", source: "process.stdout" }, observedAtField: "lastLoginObservedAt" },
presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" }, announcement: { profileKey: "scum-client-manager", commandType: "announcement.send", textField: "requestText", newTextTemplate: "#announce 欢迎新玩家 {{displayName}} 加入服务器!", returningTextTemplate: "#announce 欢迎 {{displayName}} 继续游戏!" } }
presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" } }
});
expect(projection.steps.map((step: { pattern: string }) => step.pattern)).toEqual([
'Player "(?P<displayName>[^\"]+)" reported as player (?P<slot>\\d+)',
@@ -278,7 +274,7 @@ describe("plugin manifest validation", () => {
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
});
it("declares protected database and management transports without direct access", () => {
it("declares database and management transports for direct run jobs", () => {
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
runtimeProfiles?: {
@@ -287,11 +283,11 @@ describe("plugin manifest validation", () => {
};
};
const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
expect(local?.capabilities).not.toContain("remote.run.rcon.command");
expect(local?.transportKeys).not.toContain("rcon");
expect(local?.capabilities).toContain("remote.run.rcon.command");
expect(local?.transportKeys).toContain("scum-management");
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.protected.sql"]) }),
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }),
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }),
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
]));
});
@@ -502,13 +498,15 @@ describe("plugin manifest validation", () => {
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
"announcement.send",
"companion.diagnostics",
"player.lookup",
"reward.deliver",
"player.notify",
"vehicle.spawn",
"event.start",
"restart.prepare",
"maintenance.prepare"
"maintenance.prepare",
"game-state.patch"
]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
@@ -545,13 +543,15 @@ describe("plugin manifest validation", () => {
};
};
const expected = {
"announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" },
"companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" },
"player.lookup": { permission: "server.game-client.read", approvalLevel: "none" },
"reward.deliver": { permission: "server.game-client.command", approvalLevel: "none" },
"player.notify": { permission: "server.game-client.command", approvalLevel: "none" },
"vehicle.spawn": { permission: "server.game-client.command", approvalLevel: "none" },
"event.start": { permission: "server.game-client.command", approvalLevel: "none" },
"restart.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" },
"maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" }
"maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" },
"game-state.patch": { permission: "server.game-client.maintenance", approvalLevel: "none" }
} as const;
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected)));
@@ -758,14 +758,11 @@ describe("plugin manifest validation", () => {
const operation = operationsByKey.get(key)!;
expect(operation.kind).toBe("rcon");
expect(operation.permission).toBe("server.game-client.command");
expect(operation.approvalLevel).toBe("operator");
expect(operation.safety).toMatchObject({ requiresApproval: true, requiresConfirmation: true });
expect(operation.approvalLevel).toBe("none");
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.payloadSchemaRef), "utf8"));
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.resultSchemaRef!), "utf8"));
const confirmation = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.confirmationSchemaRef!), "utf8"));
expect(payload).toMatchObject({ type: "object", additionalProperties: false });
expect(result).toMatchObject({ type: "object", additionalProperties: false });
expect(confirmation).toMatchObject({ type: "object", additionalProperties: false });
expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/rcon|commandtext|requesttext|sql|dsn|hostpath/);
}
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
@@ -915,9 +912,9 @@ describe("plugin manifest validation", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/dev-game-plugin/manifest.json"), "utf8"));
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"];
manifest.gameClientBridge = {
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
logProjections: [{ key: "player.login", streamKeys: ["process.stdout"], steps: [{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" }, { pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }], correlationFields: ["slot"], maxInterveningLines: 16, target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600, announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" } } }],
logProjections: [{ key: "player.login", streamKeys: ["process.stdout"], steps: [{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" }, { pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }], correlationFields: ["slot"], maxInterveningLines: 16, target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600 } }],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: []
@@ -942,15 +939,14 @@ describe("plugin manifest validation", () => {
presence: {
timestampField: "lastLoginAt",
activeWindowSeconds: 600,
activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" },
announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" }
activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" }
}
};
const manifest = {
permissions: ["server.game-client.command"],
runtimeProfiles: { clientManagers: [{ key: "scum-client", health: { requiredCapabilities: ["game-client.bridge"] } }] },
gameClientBridge: {
commands: [{ type: "announcement.send", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/announcement.schema.json" }],
commands: [{ type: "diagnostic.ping", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json" }],
snapshots: [],
logProjections: [projection]
}
@@ -1060,9 +1056,9 @@ describe("plugin manifest validation", () => {
expect(unsafeKeyErrors.some((error) => error.includes("operationTemplates") && error.includes("arbitrary SQL"))).toBe(true);
const approvalErrors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.operationTemplates![0].approvalLevel = "none";
manifest.gameClientBridge.operationTemplates![0].approvalLevel = "automatic";
});
expect(approvalErrors.some((error) => error.includes("approvalLevel") && error.includes("operator"))).toBe(true);
expect(approvalErrors.some((error) => error.includes("approvalLevel") && error.includes("none, operator, or platform-admin"))).toBe(true);
const rconTransportErrors = validateTemporaryBridgeManifest((manifest) => {
Object.assign(manifest.gameClientBridge.operationTemplates![0], { transportKey: "sqlite-db", targetKey: "db/sqlite" });
@@ -1117,15 +1113,15 @@ describe("plugin manifest validation", () => {
it("rejects invalid bridge schema JSON without throwing", () => {
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
fs.writeFileSync(path.join(fixtureDir, "schemas/bridge/announcement.schema.json"), "{ invalid", "utf8");
fs.writeFileSync(path.join(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json"), "{ invalid", "utf8");
});
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("not valid JSON"))).toBe(true);
});
it("rejects dangerous fields and values in payload, result, and snapshot schemas", () => {
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ shellCommand: { type: "string", const: "bash -c whoami" } }, ["shellCommand"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping-result.schema.json", bridgeObjectSchema({ shellCommand: { type: "string", const: "bash -c whoami" } }, ["shellCommand"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, mode: { type: "string", const: "run.socket" }, runCapability: { type: "string" } }, ["hostPath", "mode", "runCapability"]));
});
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("arbitrary SQL field"))).toBe(true);
@@ -1136,7 +1132,7 @@ describe("plugin manifest validation", () => {
it("requires bounded object schemas for every bridge reference", () => {
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", { type: "object", properties: { message: { type: "string" } } });
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", { type: "object", properties: { message: { type: "string" } } });
});
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("additionalProperties to false"))).toBe(true);
});
@@ -1227,7 +1223,7 @@ describe("plugin SDK", () => {
expect(declaration).toMatchObject({ key: "scum.player.login", correlationFields: ["slot"] });
});
it("types controlled operation template declarations", () => {
it("types plugin operation template declarations", () => {
const declaration: GameClientBridgeOperationTemplateDeclaration = {
key: "player.attribute.855.set",
title: "Set player attribute 855",
@@ -1252,12 +1248,12 @@ describe("plugin SDK", () => {
it("builds safe game-client bridge requests without component transport material", () => {
const request = createGameClientBridgeQueueRequest({
profileKey: "scum-client",
commandType: "announcement.send",
commandType: "diagnostic.ping",
payload: { message: "hello" },
idempotencyKey: "announcement-1",
idempotencyKey: "diagnostic-1",
expiresAt: "2026-07-20T12:00:00Z"
});
expect(request.commandType).toBe("announcement.send");
expect(request.commandType).toBe("diagnostic.ping");
expect(request).not.toHaveProperty("sessionToken");
expect(request).not.toHaveProperty("componentKey");
expect(request).not.toHaveProperty("runEndpoint");
@@ -1265,16 +1261,6 @@ describe("plugin SDK", () => {
expect(request).not.toHaveProperty("dsn");
});
it("types protected request declarations while retaining text redaction boundaries", () => {
const declaration: GameClientBridgeProtectedRequestDeclaration = { kind: "sql", transportKey: "scum-database", targetKey: "scum-database", textField: "requestText", maxTextBytes: 4096 };
expect(declaration).toMatchObject({ kind: "sql", textField: "requestText" });
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/dsn|hostpath|socket|credential|password/);
const errors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.commands[0].type = "database.request";
manifest.gameClientBridge.commands[0].protectedRequest = { kind: "sql", transportKey: "missing", targetKey: "missing", textField: "requestText", maxTextBytes: 512 };
});
expect(errors.some((error) => error.includes("protectedRequest.transportKey"))).toBe(true);
});
it("checks declared bridge permissions", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",
+5 -5
View File
@@ -37,15 +37,15 @@ const surfaceData: SCUMSurfaceData = {
};
describe("SCUM plugin feature module", () => {
it("owns runtime allowlists without a version gate", () => {
it("owns runtime catalogs without a version gate", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围");
expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull();
expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("白名单");
expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("插件运行时目录");
expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]);
expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("受控目录");
expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("插件目录");
});
it("maps transitional records only as read-only provenance", () => {
@@ -53,7 +53,7 @@ describe("SCUM plugin feature module", () => {
expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] });
});
it("preserves only allowlisted transitional history for every feature area", () => {
it("preserves only declared transitional history for every feature area", () => {
expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } });
expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } });
expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true });
@@ -61,7 +61,7 @@ describe("SCUM plugin feature module", () => {
expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true });
});
it("matches controlled transitional fixtures without carrying sensitive fields into plugin history", () => {
it("matches transitional fixtures without carrying sensitive fields into plugin history", () => {
expect(migrateConfigurationRecord(scumMigrationParityFixtures.configuration.source)).toEqual(scumMigrationParityFixtures.configuration.expected);
expect(migratePlayerProfileRecord(scumMigrationParityFixtures.playerHistory.source)).toEqual(scumMigrationParityFixtures.playerHistory.expected);
expect(migrateGiftGrantRecord(scumMigrationParityFixtures.gift.source)).toEqual(scumMigrationParityFixtures.gift.expected);