Integrate SCUM real ops workflows
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeOperationTemplateDeclaration,
|
||||
type GameClientBridgeProtectedRequestDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
type GamePluginManifest,
|
||||
@@ -54,6 +55,7 @@ type MutableBridgeManifest = {
|
||||
commands: Array<Record<string, unknown>>;
|
||||
snapshots: Array<Record<string, unknown>>;
|
||||
queryTemplates?: Array<Record<string, unknown>>;
|
||||
operationTemplates?: Array<Record<string, unknown>>;
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
pages: Array<Record<string, unknown>>;
|
||||
@@ -92,30 +94,44 @@ 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"];
|
||||
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"];
|
||||
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query"], databaseEngines: ["sqlite"] };
|
||||
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.protected.rcon", "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.runtimeProfiles = {
|
||||
transportProfiles: [{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] }]
|
||||
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-mutation-db", kind: "sqlite", targetKey: "scum-mutation-db", capabilities: ["remote.run.protected.sql"] }
|
||||
]
|
||||
};
|
||||
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
||||
if (overviewPage) {
|
||||
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.remote.access"];
|
||||
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access"];
|
||||
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 }],
|
||||
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.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"] }]
|
||||
pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], 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/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"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, fame: { type: "integer", minimum: 0, maximum: 2147483647 } }, ["playerId", "fame"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["queued", "succeeded", "failed", "unknown"] } }, ["outcome"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, fame: { type: "integer" } }, ["playerId", "fame"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, before: { type: "number" }, after: { type: "number" }, safetyWindow: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId", "before", "after", "safetyWindow"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["succeeded", "failed", "unknown"] }, rowsAffected: { type: "integer", minimum: 0, maximum: 1 } }, ["outcome", "rowsAffected"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, value: { type: "number" } }, ["playerId", "value"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
@@ -174,19 +190,16 @@ describe("plugin manifest validation", () => {
|
||||
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares bounded protected SQL and management request surfaces", () => {
|
||||
it("removes raw protected SQL and management request 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; resultSchemaRef?: string; protectedRequest?: { kind: string; textField: string; transportKey: string; targetKey: string } }> } };
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: 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"] } } });
|
||||
}
|
||||
expect(commands).toEqual([]);
|
||||
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"]));
|
||||
expect(manifest.gameClientBridge.operationTemplates.find((operation) => operation.key === "player.attribute.855.set")?.kind).toBe("sqlite-mutation");
|
||||
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
|
||||
@@ -257,7 +270,7 @@ describe("plugin manifest validation", () => {
|
||||
expect(local?.capabilities).not.toContain("remote.run.rcon.command");
|
||||
expect(local?.transportKeys).not.toContain("rcon");
|
||||
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: ["remote.run.protected.sql"] }),
|
||||
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-program", kind: "program", capabilities: ["remote.run.program.command"] })
|
||||
]));
|
||||
@@ -439,7 +452,7 @@ describe("plugin manifest validation", () => {
|
||||
maxPayloadBytes: number;
|
||||
}>;
|
||||
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[] }>;
|
||||
fileWorkspace?: {
|
||||
@@ -478,25 +491,22 @@ describe("plugin manifest validation", () => {
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
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)).toContain("files-config");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config")?.commandTypes).toEqual(expect.arrayContaining([
|
||||
"announcement.send",
|
||||
"companion.diagnostics",
|
||||
"player.lookup",
|
||||
"reward.deliver",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.operationKeys).toEqual(expect.arrayContaining([
|
||||
"player.fame.set",
|
||||
"player.currency.normal.set",
|
||||
"player.currency.gold.set",
|
||||
"player.notify",
|
||||
"player.attribute.855.set"
|
||||
]));
|
||||
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["files-config", "players", "squads", "live-map", "gifts"]));
|
||||
expect(manifest.pages.find((page) => page.key === "files-config")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||
expect(manifest.pages.map((page) => page.key)).not.toContain("files-config");
|
||||
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config");
|
||||
expect(manifest.fileWorkspace?.directories.map((directory) => directory.key)).toEqual(["scum-config", "scum-logs"]);
|
||||
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-game-config", "scum-engine-config", "scum-game-user-settings", "scum-admin-log", "scum-chat-log", "scum-kill-log", "scum-login-log", "scum-server-log"]));
|
||||
expect(manifest.fileWorkspace?.configFields.every((field) => field.fileKey === "scum-server-settings")).toBe(true);
|
||||
expect(manifest.fileWorkspace).toBeUndefined();
|
||||
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
|
||||
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"]));
|
||||
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-client-events"]));
|
||||
});
|
||||
|
||||
it("declares bounded and permissioned SCUM bridge commands", () => {
|
||||
@@ -601,17 +611,22 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
|
||||
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes));
|
||||
const pageSnapshotTypes = manifest.gameClientBridge.pages.flatMap((page) => page.snapshotTypes ?? []);
|
||||
expect(pageSnapshotTypes).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "online.sessions"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
|
||||
});
|
||||
|
||||
it("does not declare direct database query templates", () => {
|
||||
it("declares typed SCUM.db query templates without browser-visible SQL", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
capabilities: string[];
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
gameClientBridge: {
|
||||
queryTemplates: Array<{
|
||||
key: string;
|
||||
title?: string;
|
||||
permission: string;
|
||||
engine: string;
|
||||
transportKey: string;
|
||||
@@ -626,12 +641,72 @@ describe("plugin manifest validation", () => {
|
||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||
};
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
|
||||
const operationsPluginPage = manifest.pages.find((page) => page.key === "files-config");
|
||||
expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]);
|
||||
expect(operationsPage?.queryTemplateKeys ?? []).toEqual([]);
|
||||
expect(operationsPluginPage?.permissions).not.toContain("server.remote.access");
|
||||
expect(operationsPluginPage?.bridgeActions).not.toContain("remote.access.request");
|
||||
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite");
|
||||
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
|
||||
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" });
|
||||
expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query"]));
|
||||
for (const key of expectedKeys) {
|
||||
const template = templatesByKey.get(key)!;
|
||||
expect(template.engine).toBe("sqlite");
|
||||
expect(template.transportKey).toBe("scum-database");
|
||||
expect(template.targetKey).toBe("scum-database");
|
||||
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/);
|
||||
const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8"));
|
||||
expect(parameters).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] });
|
||||
expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows);
|
||||
}
|
||||
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
||||
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
||||
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
|
||||
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
|
||||
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
for (const pageKey of ["players", "squads", "live-map"]) {
|
||||
const pluginPage = manifest.pages.find((page) => page.key === pageKey);
|
||||
expect(pluginPage?.permissions).toContain("server.game-client.read");
|
||||
expect(pluginPage?.bridgeActions).toContain("remote.access.request");
|
||||
}
|
||||
});
|
||||
|
||||
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: {
|
||||
operationTemplates: Array<{ key: string; kind: string; permission: string; approvalLevel: string; payloadSchemaRef: string; resultSchemaRef?: string; confirmationSchemaRef?: string; safety?: Record<string, boolean> }>;
|
||||
pages: Array<{ pageKey: string; operationKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[] }>;
|
||||
};
|
||||
const expectedKeys = ["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver"];
|
||||
const operationsByKey = new Map(manifest.gameClientBridge.operationTemplates.map((operation) => [operation.key, operation]));
|
||||
expect([...operationsByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
for (const key of expectedKeys) {
|
||||
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 });
|
||||
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");
|
||||
const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts");
|
||||
expect(playersPage?.operationKeys).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify"]));
|
||||
expect(giftsPage?.operationKeys).toEqual(expect.arrayContaining(["reward.deliver", "player.notify"]));
|
||||
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toContain("server.game-client.command");
|
||||
expect(manifest.pages.find((page) => page.key === "gifts")?.permissions).toContain("server.game-client.command");
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
@@ -856,6 +931,35 @@ describe("plugin manifest validation", () => {
|
||||
expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true);
|
||||
});
|
||||
|
||||
it("validates typed operation templates and page operation bindings", () => {
|
||||
expect(validateTemporaryBridgeManifest()).toEqual([]);
|
||||
|
||||
const unsafeKeyErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![0].key = "raw.sql.execute";
|
||||
});
|
||||
expect(unsafeKeyErrors.some((error) => error.includes("operationTemplates") && error.includes("arbitrary SQL"))).toBe(true);
|
||||
|
||||
const approvalErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![0].approvalLevel = "none";
|
||||
});
|
||||
expect(approvalErrors.some((error) => error.includes("approvalLevel") && error.includes("operator"))).toBe(true);
|
||||
|
||||
const rconTransportErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
Object.assign(manifest.gameClientBridge.operationTemplates![0], { transportKey: "sqlite-db", targetKey: "db/sqlite" });
|
||||
});
|
||||
expect(rconTransportErrors.some((error) => error.includes("rcon operations require"))).toBe(true);
|
||||
|
||||
const mutationSafetyErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![1].safety = { requiresConfirmation: true };
|
||||
});
|
||||
expect(mutationSafetyErrors.some((error) => error.includes("sqlite-mutation operations require before value"))).toBe(true);
|
||||
|
||||
const pageErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.pages[0].operationKeys = ["missing.operation"];
|
||||
});
|
||||
expect(pageErrors.some((error) => error.includes("undeclared operation template missing.operation"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName]));
|
||||
@@ -990,6 +1094,28 @@ describe("plugin SDK", () => {
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
|
||||
});
|
||||
|
||||
it("types controlled operation template declarations", () => {
|
||||
const declaration: GameClientBridgeOperationTemplateDeclaration = {
|
||||
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/operations/player-attribute-855-set.payload.schema.json",
|
||||
resultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json",
|
||||
confirmationSchemaRef: "schemas/bridge/operations/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.lookup", allowedValueType: "integer", minValue: 0, maxValue: 100000 },
|
||||
safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true }
|
||||
};
|
||||
expect(declaration).toMatchObject({ kind: "sqlite-mutation", approvalLevel: "platform-admin", maxRowsAffected: 1 });
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential|password/);
|
||||
});
|
||||
|
||||
it("builds safe game-client bridge requests without component transport material", () => {
|
||||
const request = createGameClientBridgeQueueRequest({
|
||||
profileKey: "scum-client",
|
||||
|
||||
Reference in New Issue
Block a user