refactor(scum): declare protected run requests
This commit is contained in:
@@ -5,12 +5,13 @@ feature gate: no SCUM game, database, UE4SS build, or source revision controls
|
||||
plugin availability.
|
||||
|
||||
The Companion declares availability from its server-bound typed ports and
|
||||
runtime schema probes. Notification and fixed vehicle spawning can use a local
|
||||
typed transport, but callers never supply a command, socket, credential, path,
|
||||
or raw transport reply. Vehicle spawning creates only the private
|
||||
`#spawnvehicle <vehicleCode>` template from the plugin allowlist.
|
||||
runtime schema probes. Plugin-generated SQL, RCON, and management-program
|
||||
request text is declared through the protected Platform-to-Run transport, not
|
||||
executed by the Companion. Callers never receive a socket, credential, path,
|
||||
or raw transport reply. A management-program request is not host OS shell
|
||||
access.
|
||||
|
||||
Semantic events come from bounded Run stdout/stderr records. Unknown records
|
||||
create diagnostics and never produce fabricated events. Run database access is
|
||||
limited to typed allowlisted projections and safe mutations; DSNs, rows, SQL,
|
||||
and credentials do not leave Run.
|
||||
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.
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedUE4SSNotificationIsFixedTypedAndRedacted(t *testing.T) {
|
||||
func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) {
|
||||
port := ¬ificationPortFixture{accepted: true}
|
||||
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
|
||||
result, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moon \"gift\""})
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
)
|
||||
|
||||
// SafeAdapter is intentionally narrow: it receives typed values only and has
|
||||
// no raw RCON, SQL, host-path, credential, or shell access.
|
||||
// no direct transport, host-path, credential, or shell access. Protected SQL,
|
||||
// RCON, and management-program text is forwarded to Run by Platform, not here.
|
||||
type SafeAdapter interface {
|
||||
ReadConfiguration(context.Context) (map[string]any, error)
|
||||
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.logs.transfer",
|
||||
"remote.run.protected.sql",
|
||||
"remote.run.protected.rcon",
|
||||
"remote.run.program.command",
|
||||
"client-manager.deploy",
|
||||
"client-manager.control",
|
||||
"client-manager.update",
|
||||
@@ -67,7 +70,10 @@
|
||||
"remote.run.files.write",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.logs.transfer"
|
||||
"remote.run.logs.transfer",
|
||||
"remote.run.protected.sql",
|
||||
"remote.run.protected.rcon",
|
||||
"remote.run.program.command"
|
||||
],
|
||||
"logTransfer": true
|
||||
},
|
||||
@@ -190,6 +196,39 @@
|
||||
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 4096
|
||||
},
|
||||
{
|
||||
"type": "database.request",
|
||||
"title": "Execute approved SCUM database request",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"approvalLevel": "platform-admin",
|
||||
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 16384,
|
||||
"protectedRequest": { "kind": "sql", "transportKey": "scum-database", "targetKey": "scum-database", "textField": "requestText", "maxTextBytes": 16384 }
|
||||
},
|
||||
{
|
||||
"type": "management.rcon.request",
|
||||
"title": "Execute approved SCUM management command",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "operator",
|
||||
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 8192,
|
||||
"protectedRequest": { "kind": "rcon", "transportKey": "scum-management", "targetKey": "scum-management", "textField": "requestText", "maxTextBytes": 8192 }
|
||||
},
|
||||
{
|
||||
"type": "management.program.request",
|
||||
"title": "Execute approved SCUM management program request",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"approvalLevel": "platform-admin",
|
||||
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 8192,
|
||||
"protectedRequest": { "kind": "program", "transportKey": "scum-program", "targetKey": "scum-program", "textField": "requestText", "maxTextBytes": 8192 }
|
||||
}
|
||||
],
|
||||
"snapshots": [
|
||||
@@ -271,7 +310,10 @@
|
||||
"vehicle.spawn",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
"maintenance.prepare",
|
||||
"database.request",
|
||||
"management.rcon.request",
|
||||
"management.program.request"
|
||||
],
|
||||
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
|
||||
"featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"]
|
||||
@@ -482,7 +524,6 @@
|
||||
],
|
||||
"discoveryMarkers": [
|
||||
{ "key": "scum-executable", "kind": "file.exists", "targetKey": "scum/server-executable", "required": true },
|
||||
{ "key": "scum-version", "kind": "command.version", "targetKey": "scum/server-executable", "required": true },
|
||||
{ "key": "scum-steam-app", "kind": "steam.app", "targetKey": "server/install-root", "expected": "3792580", "required": true },
|
||||
{ "key": "scum-config", "kind": "file.exists", "targetKey": "scum/server-settings", "expected": "ServerSettings.ini", "required": true },
|
||||
{ "key": "scum-game-port", "kind": "port.open", "targetKey": "game-port", "required": true },
|
||||
@@ -490,7 +531,6 @@
|
||||
],
|
||||
"verificationChecks": [
|
||||
{ "key": "executable", "kind": "executable.present", "targetKey": "scum/server-executable", "required": true },
|
||||
{ "key": "version", "kind": "version.matches", "targetKey": "scum/server-executable", "required": true },
|
||||
{ "key": "game-port", "kind": "port.bound", "targetKey": "game-port", "required": true },
|
||||
{ "key": "config", "kind": "config.readable", "targetKey": "scum/server-settings", "required": true },
|
||||
{ "key": "process", "kind": "process.healthy", "targetKey": "scum/server-executable", "required": true }
|
||||
@@ -498,6 +538,22 @@
|
||||
}
|
||||
],
|
||||
"logSources": [
|
||||
{
|
||||
"key": "scum-console-stdout",
|
||||
"kind": "process.stdout",
|
||||
"targetKey": "scum/server-process",
|
||||
"streamKey": "scum.console.stdout",
|
||||
"cursorKind": "sequence",
|
||||
"retentionDays": 30
|
||||
},
|
||||
{
|
||||
"key": "scum-console-stderr",
|
||||
"kind": "process.stderr",
|
||||
"targetKey": "scum/server-process",
|
||||
"streamKey": "scum.console.stderr",
|
||||
"cursorKind": "sequence",
|
||||
"retentionDays": 30
|
||||
},
|
||||
{
|
||||
"key": "scum-chat-events",
|
||||
"kind": "file.tail",
|
||||
@@ -686,6 +742,24 @@
|
||||
"remote.rsync.read",
|
||||
"remote.rsync.write"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "scum-database",
|
||||
"kind": "sqlite",
|
||||
"targetKey": "scum-database",
|
||||
"capabilities": ["remote.run.protected.sql"]
|
||||
},
|
||||
{
|
||||
"key": "scum-management",
|
||||
"kind": "rcon",
|
||||
"targetKey": "scum-management",
|
||||
"capabilities": ["remote.run.protected.rcon"]
|
||||
},
|
||||
{
|
||||
"key": "scum-program",
|
||||
"kind": "program",
|
||||
"targetKey": "scum-program",
|
||||
"capabilities": ["remote.run.program.command"]
|
||||
}
|
||||
],
|
||||
"clientManagers": [
|
||||
@@ -742,10 +816,6 @@
|
||||
"offlineAfterSeconds": 120,
|
||||
"requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"]
|
||||
},
|
||||
"compatibility": {
|
||||
"minimumVersion": "1.0.0",
|
||||
"allowDowngrade": false
|
||||
},
|
||||
"updatePolicy": {
|
||||
"strategy": "manual-staged",
|
||||
"requireApproval": true,
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["requestText"],
|
||||
"properties": { "requestText": { "type": "string", "minLength": 1, "maxLength": 16384 } }
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["outcome"],
|
||||
"properties": { "outcome": { "enum": ["succeeded", "failed", "unknown"] }, "diagnostic": { "type": "string", "maxLength": 512 } }
|
||||
}
|
||||
@@ -299,7 +299,20 @@
|
||||
"payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
|
||||
"maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }
|
||||
"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 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeSnapshot": {
|
||||
@@ -384,6 +397,9 @@
|
||||
"remote.run.db.sqlite.query",
|
||||
"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",
|
||||
"client-manager.update",
|
||||
@@ -605,7 +621,7 @@
|
||||
"prerequisites": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerPrerequisite" }, "maxItems": 16 },
|
||||
"configMappings": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerConfigMapping" }, "minItems": 1, "maxItems": 32, "uniqueItems": true },
|
||||
"discoveryMarkers": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerDiscoveryMarker" }, "minItems": 1, "maxItems": 32, "uniqueItems": true },
|
||||
"verificationChecks": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerVerificationCheck" }, "minItems": 5, "maxItems": 16, "uniqueItems": true }
|
||||
"verificationChecks": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerVerificationCheck" }, "minItems": 4, "maxItems": 16, "uniqueItems": true }
|
||||
}
|
||||
},
|
||||
"runtimeServerPrerequisite": {
|
||||
@@ -651,7 +667,7 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"kind": { "enum": ["file", "ftp", "rsync", "mysql", "sqlite", "rcon"] },
|
||||
"kind": { "enum": ["file", "ftp", "rsync", "mysql", "sqlite", "rcon", "program"] },
|
||||
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 }
|
||||
}
|
||||
@@ -789,7 +805,7 @@
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "required": ["deployment"] },
|
||||
"then": { "required": ["version", "lifecycle", "health", "compatibility", "updatePolicy"] }
|
||||
"then": { "required": ["version", "lifecycle", "health", "updatePolicy"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -393,7 +393,7 @@ function validateServerDeploymentProfiles(manifest: unknown): string[] {
|
||||
if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`);
|
||||
mappingKeys.add(mapping.fieldKey);
|
||||
}
|
||||
const requiredChecks = new Set(["executable.present", "version.matches", "port.bound", "config.readable", "process.healthy"]);
|
||||
const requiredChecks = new Set(["executable.present", "port.bound", "config.readable", "process.healthy"]);
|
||||
for (const check of profile.verificationChecks ?? []) {
|
||||
if (check.required) requiredChecks.delete(check.kind);
|
||||
}
|
||||
@@ -601,7 +601,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string };
|
||||
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 BridgeQueryTemplate = {
|
||||
key?: string;
|
||||
permission?: string;
|
||||
@@ -693,13 +694,44 @@ 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 = unsafeGameClientBridgeCommandTypeReason(type);
|
||||
const unsafeTypeReason = command.protectedRequest ? undefined : 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`);
|
||||
|
||||
@@ -37,7 +37,7 @@ Run distribution, dependency, log backfill, and client-manager requests use `cre
|
||||
|
||||
Client-manager lifecycle requests remain Platform-mediated. A plugin declaration does not grant access by itself: Platform rechecks the installed plugin, server owner/administrator scope, runtime binding, assigned Run endpoint capabilities, current distribution target/revision/key generation, and durable installation state before dispatching a typed job.
|
||||
|
||||
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields.
|
||||
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, redacts, queues, and forwards it to Run. A management program is not host OS shell access. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields.
|
||||
|
||||
Production plugin lifecycle requests use `createProductionPluginLifecycleRequest`. Envelopes contain only plugin/server scope, enumerated operation, optional target version, confirmation, and idempotency key. Platform rechecks the manifest `productionLifecycle` declaration, dependency policy, disruptive approval, endpoint capacity, compatibility, and prior idempotency inputs before dispatch.
|
||||
|
||||
|
||||
+14
-3
@@ -211,7 +211,17 @@ export interface GamePluginRemoteAccess {
|
||||
|
||||
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
|
||||
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
|
||||
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired";
|
||||
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;
|
||||
@@ -221,7 +231,8 @@ export interface GameClientBridgeCommandDeclaration {
|
||||
payloadSchemaRef: string;
|
||||
resultSchemaRef?: string;
|
||||
timeoutSeconds: number;
|
||||
maxPayloadBytes: number;
|
||||
maxPayloadBytes: number;
|
||||
protectedRequest?: GameClientBridgeProtectedRequestDeclaration;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeSnapshotDeclaration {
|
||||
@@ -306,7 +317,7 @@ export interface GameClientBridgeStatus {
|
||||
}
|
||||
|
||||
export interface GameClientBridgeCommandResult {
|
||||
status: "succeeded" | "failed" | "cancelled";
|
||||
status: "succeeded" | "failed" | "unknown" | "cancelled";
|
||||
summary?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
completedAt: string;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeProtectedRequestDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
type GamePluginManifest,
|
||||
type RuntimeLogEventDeclaration,
|
||||
@@ -173,17 +174,19 @@ describe("plugin manifest validation", () => {
|
||||
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares a fixed, schema-bound vehicle spawn instead of a raw command surface", () => {
|
||||
it("declares bounded protected SQL and management request 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; resultSchemaRef?: string }>; features: Array<{ key: string; requiredHandlers?: string[] }> } };
|
||||
const command = manifest.gameClientBridge.commands.find((candidate) => candidate.type === "vehicle.spawn");
|
||||
expect(command).toBeDefined();
|
||||
expect(manifest.gameClientBridge.features.find((feature) => feature.key === "vehicle.spawn")?.requiredHandlers).toEqual(["vehicle.spawn"]);
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.payloadSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.resultSchemaRef!), "utf8"));
|
||||
expect(payload).toMatchObject({ additionalProperties: false, required: ["vehicleCode"], properties: { vehicleCode: { enum: ["BPC_Laika_C", "BPC_WolfsWagen_C"] } } });
|
||||
expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/command|rcon|target|credential|socket|shell|sql/);
|
||||
expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string; protectedRequest?: { kind: string; textField: string; transportKey: string; targetKey: string } }> } };
|
||||
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
|
||||
expect(commands.map((command) => command.protectedRequest?.kind)).toEqual(expect.arrayContaining(["sql", "rcon", "program"]));
|
||||
for (const command of commands) {
|
||||
expect(command.protectedRequest?.textField).toBe("requestText");
|
||||
expect(command.protectedRequest?.transportKey).toBe(command.protectedRequest?.targetKey);
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command.payloadSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command.resultSchemaRef!), "utf8"));
|
||||
expect(payload).toMatchObject({ additionalProperties: false, required: ["requestText"] });
|
||||
expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } });
|
||||
}
|
||||
});
|
||||
|
||||
it("declares a frozen SCUM install/adopt template with explicit mapping and verification checks", () => {
|
||||
@@ -191,7 +194,7 @@ describe("plugin manifest validation", () => {
|
||||
const template = manifest.runtimeProfiles.serverDeployments[0];
|
||||
expect(template).toMatchObject({ key: "scum-steamcmd-windows", version: "1.0.0", steamAppId: "3792580", configFormat: "ini" });
|
||||
expect(template.configMappings.map((mapping: any) => mapping.fieldKey)).toEqual(["serverName", "gamePort", "queryPort", "maxPlayers"]);
|
||||
expect(template.verificationChecks.filter((check: any) => check.required)).toHaveLength(5);
|
||||
expect(template.verificationChecks.filter((check: any) => check.required)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("rejects an SCUM template mapping an undeclared field", () => {
|
||||
@@ -212,18 +215,22 @@ describe("plugin manifest validation", () => {
|
||||
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removes direct RCON, database, and DLL extension declarations", () => {
|
||||
it("declares protected database and management transports without direct access", () => {
|
||||
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
runtimeProfiles?: {
|
||||
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>;
|
||||
transportProfiles?: Array<{ kind?: string }>;
|
||||
transportProfiles?: Array<{ key?: string; kind?: string; capabilities?: string[] }>;
|
||||
};
|
||||
};
|
||||
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(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" || profile.kind === "mysql" || profile.kind === "rcon")).toBe(false);
|
||||
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: ["remote.run.protected.sql"] }),
|
||||
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }),
|
||||
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
|
||||
]));
|
||||
});
|
||||
|
||||
it("defines a generated SCUM companion config without inline proof or session material", () => {
|
||||
@@ -588,7 +595,7 @@ describe("plugin manifest validation", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<{ key: string; retentionDays?: number }>;
|
||||
logSources?: Array<{ key: string; kind?: string; streamKey?: string; retentionDays?: number }>;
|
||||
logEvents?: Array<RuntimeLogEventDeclaration>;
|
||||
};
|
||||
};
|
||||
@@ -597,6 +604,8 @@ describe("plugin manifest validation", () => {
|
||||
const logEvents = manifest.runtimeProfiles?.logEvents ?? [];
|
||||
|
||||
expect(logEvents.map((event) => event.eventType)).toEqual(expect.arrayContaining(expectedTypes));
|
||||
expect(logSources.get("scum-console-stdout")).toMatchObject({ kind: "process.stdout", streamKey: "scum.console.stdout" });
|
||||
expect(logSources.get("scum-console-stderr")).toMatchObject({ kind: "process.stderr", streamKey: "scum.console.stderr" });
|
||||
expect(new Set(logEvents.map((event) => event.key)).size).toBe(logEvents.length);
|
||||
expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length);
|
||||
for (const event of logEvents) {
|
||||
@@ -949,6 +958,17 @@ describe("plugin SDK", () => {
|
||||
expect(request).not.toHaveProperty("hostPath");
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user