Remove pre-1.0 audit and protected request scaffolding
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user