Revert SCUM real data management change

This commit is contained in:
npc0-hue
2026-08-13 15:33:34 +08:00
parent d831e4ade9
commit b07a792784
163 changed files with 5443 additions and 9174 deletions
+27 -418
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -31,7 +30,6 @@ import {
type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration,
type GamePluginManifest,
type SCUMLiveDataManifestDeclaration,
type RuntimeLogEventDeclaration,
type RuntimeClientManagerProfile,
type PluginLifecycleActionDeclaration,
@@ -80,97 +78,6 @@ function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unkno
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
const currentSCUMSchemaFingerprint = "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0";
const scumSQLiteQueryAssetVersion = "scum-sqlite-query-v1";
const scumPaginatedQueryKeys = new Set(["scum-players-read", "scum-squads-read", "scum-squad-members-read", "scum-vehicles-read", "scum-flags-read", "scum-positions-read"]);
const unsafeSCUMSQLiteReadStatementPattern = /;|\b(INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER|CREATE|VACUUM|ATTACH|DETACH|ANALYZE|REINDEX)\b|\bload_extension\s*\(|\bPRAGMA\s+(?!(?:table_info|foreign_key_list|index_list|index_info|schema_version|data_version)\b)/i;
type SCUMSQLiteQueryAssetParameter = {
name: string;
binding: string;
type?: string;
required?: boolean;
nullable?: boolean;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
enum?: string[];
};
type SCUMSQLiteQueryAsset = {
key?: string;
adapterVersion?: string;
queryVersion?: string;
capability?: string;
requiredSchemaFingerprint?: string;
statementType?: string;
statement?: string;
parameters?: SCUMSQLiteQueryAssetParameter[];
safety?: {
queryOnly?: boolean;
readOnlyConnection?: boolean;
forbidMultipleStatements?: boolean;
forbidAttach?: boolean;
forbidWritePragmas?: boolean;
forbidExtensionLoading?: boolean;
maxRows?: number;
timeoutMs?: number;
maxResultBytes?: number;
};
};
type SCUMMapBounds = { minX: number; minY: number; maxX: number; maxY: number };
type SCUMMapImage = { width: number; height: number };
type SCUMMapTransformFixture = { name: string; world: { x: number; y: number }; pixel: { x: number; y: number } };
type SCUMMapTransformAsset = {
key?: string;
transformVersion?: string;
mapAssetKey?: string;
adapterVersion?: string;
requiredSchemaFingerprint?: string;
worldBounds: SCUMMapBounds;
image: SCUMMapImage;
validation?: { rejectNonFinite?: boolean; rejectOutOfBounds?: boolean; acceptBoundaryPoints?: boolean };
fixtures?: SCUMMapTransformFixture[];
};
type SCUMMapProjection = { ok: true; pixel: { x: number; y: number } } | { ok: false; reason: "non-finite" | "out-of-bounds" };
type JSONSchemaObject = {
type?: unknown;
additionalProperties?: boolean;
required?: string[];
properties?: Record<string, Record<string, unknown>>;
};
function sortedValues(values: Iterable<string>): string[] {
return [...values].sort((left, right) => left.localeCompare(right));
}
function extractSQLiteParameterBindings(statement: string): string[] {
return [...statement.matchAll(/:[A-Za-z_][A-Za-z0-9_]*/g)].map((match) => match[0]);
}
function expectSCUMSQLiteReadStatementBoundary(statementType: string | undefined, statement: string | undefined): void {
expect(statementType).toBe("single-select-or-cte");
const trimmed = statement?.trim() ?? "";
expect(trimmed).toMatch(/^(WITH|SELECT)\b/i);
expect(trimmed).not.toMatch(unsafeSCUMSQLiteReadStatementPattern);
}
function sha256FixtureDigest(fixtureDir: string, relativePath: string): string {
return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(path.join(fixtureDir, relativePath))).digest("hex")}`;
}
function projectSCUMMapCoordinate(transform: SCUMMapTransformAsset, world: { x: number; y: number }): SCUMMapProjection {
const { minX, minY, maxX, maxY } = transform.worldBounds;
const { width, height } = transform.image;
if (![world.x, world.y, minX, minY, maxX, maxY, width, height].every(Number.isFinite)) return { ok: false, reason: "non-finite" };
if (world.x < minX || world.x > maxX || world.y < minY || world.y > maxY) return { ok: false, reason: "out-of-bounds" };
return { ok: true, pixel: { x: ((world.x - minX) / (maxX - minX)) * (width - 1), y: ((maxY - world.y) / (maxY - minY)) * (height - 1) } };
}
function listProductionGoFiles(directory: string): string[] {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(directory, entry.name);
@@ -283,306 +190,16 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
});
it("removes unverified SCUM query and operation declarations", () => {
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; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
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).toEqual([]);
expect(manifest.gameClientBridge.operationTemplates).toEqual([]);
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(false);
});
it("matches SCUM live-data gates to the current-service adapter matrix", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration; remoteAccess: { runCapabilities: string[] } };
expect(manifest.capabilities).toContain("remote.run.db.sqlite.probe");
expect(manifest.remoteAccess.runCapabilities).toContain("remote.run.db.sqlite.probe");
expect(manifest.scumLiveData.probe).toMatchObject({ capability: "remote.run.db.sqlite.probe", targetKey: "scum-database" });
expect(manifest.runtimeProfiles?.dataTargets?.find((target) => target.key === "scum-database")).toMatchObject({ kind: "sqlite.snapshot", transportKey: "scum-database", sourceRootKey: "server-root", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database", refreshPolicy: "on-demand-snapshot" });
const gates = new Map(manifest.scumLiveData.capabilityGates.map((gate) => [gate.capability, gate]));
expect(gates.get("schema-probe")).toMatchObject({ gate: "enabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, evidenceStatus: "compatible" });
const queryDigestsByCapability = new Map<string, string[]>();
for (const query of manifest.scumLiveData.sqliteQueries ?? []) {
const digests = queryDigestsByCapability.get(query.capability) ?? [];
digests.push(query.digest);
queryDigestsByCapability.set(query.capability, digests);
}
const mapAsset = manifest.scumLiveData.mapAssets?.[0];
if (mapAsset) queryDigestsByCapability.set("positions.read", [...(queryDigestsByCapability.get("positions.read") ?? []), mapAsset.digest, mapAsset.transformDigest]);
for (const [capability, digests] of queryDigestsByCapability) {
const gate = gates.get(capability as any);
expect(gate).toMatchObject({ gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, evidenceStatus: "missing" });
for (const digest of digests) expect(gate?.requiredAssetDigests).toContain(digest);
}
expect([...gates.keys()]).toEqual(expect.arrayContaining(["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"]));
for (const capability of ["profile-xml.write", "economy-command.write", "gift-command.write"] as const) expect(gates.get(capability)).toMatchObject({ gate: "disabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing" });
expect(JSON.stringify(manifest.scumLiveData).toLowerCase()).not.toMatch(/select\s+.+from|sqlite:\/\/|mysql:\/\/|password|credential|socket|hostpath/);
});
it("omits unverified SCUM typed RCON templates and gift catalogs", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration };
const liveData = manifest.scumLiveData;
const gates = new Map(liveData.capabilityGates.map((gate) => [gate.capability, gate]));
expect(liveData.typedRconTemplates ?? []).toEqual([]);
expect(liveData.giftCatalogs ?? []).toEqual([]);
expect(gates.get("economy-command.write")).toMatchObject({ gate: "disabled", evidenceStatus: "missing" });
expect(gates.get("gift-command.write")).toMatchObject({ gate: "disabled", evidenceStatus: "missing" });
expect((manifest.assetFiles ?? []).map((file) => file.path).filter((assetPath) => /assets\/scum-live\/(?:rcon|gifts)\//.test(assetPath))).toEqual([]);
expect(JSON.stringify(liveData).toLowerCase()).not.toMatch(/#setcurrency|#setfame|sendchat|commandtext|rawcommand|starter-pack|bandage|water-bottle|improvised-spear/);
});
it("packages SCUM live read query assets with exact bounded result schemas", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration };
const assetPaths = new Set(manifest.assetFiles?.map((file) => file.path) ?? []);
const queries = manifest.scumLiveData.sqliteQueries ?? [];
expect(manifest.scumLiveData.schemaVersion).toBe("1");
expect(queries.map((query) => query.key)).toEqual([
"scum-players-read",
"scum-player-details-read",
"scum-player-economy-read",
"scum-player-session-enrichment-read",
"scum-squads-read",
"scum-squad-members-read",
"scum-vehicles-read",
"scum-flags-read",
"scum-positions-read"
]);
for (const query of queries) {
expect(assetPaths.has(query.assetPath)).toBe(true);
const assetPath = path.join(pluginDir, query.assetPath);
const actualDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(assetPath)).digest("hex")}`;
expect(query.digest).toBe(actualDigest);
const asset = JSON.parse(fs.readFileSync(assetPath, "utf8")) as SCUMSQLiteQueryAsset;
expect(asset).toMatchObject({ key: `${query.key}-v1`, adapterVersion: query.adapterVersion, queryVersion: scumSQLiteQueryAssetVersion, capability: query.capability });
expect(query.requiredSchemaFingerprint).toBe(currentSCUMSchemaFingerprint);
expect(asset.requiredSchemaFingerprint).toBe(query.requiredSchemaFingerprint);
expectSCUMSQLiteReadStatementBoundary(asset.statementType, asset.statement);
expect(query.maxRows).toBeGreaterThan(0);
expect(query.maxRows).toBeLessThanOrEqual(500);
expect(query.timeoutMs).toBeGreaterThan(0);
expect(query.timeoutMs).toBeLessThanOrEqual(60000);
expect(query.maxResultBytes).toBeGreaterThan(0);
expect(query.maxResultBytes).toBeLessThanOrEqual(1048576);
expect(asset.safety).toMatchObject({ queryOnly: true, readOnlyConnection: true, forbidMultipleStatements: true, forbidAttach: true, forbidWritePragmas: true, forbidExtensionLoading: true });
expect(asset.safety?.maxRows).toBeLessThanOrEqual(query.maxRows);
expect(asset.safety?.timeoutMs).toBeLessThanOrEqual(query.timeoutMs);
expect(asset.safety?.maxResultBytes).toBeLessThanOrEqual(query.maxResultBytes);
if (["scum-squads-read", "scum-squad-members-read", "scum-vehicles-read", "scum-flags-read"].includes(query.key)) {
expect(asset.statement).toContain("CAST(NULL");
}
for (const schemaRef of [query.parameterSchemaRef, query.resultSchemaRef]) {
expect(fs.existsSync(path.join(pluginDir, schemaRef))).toBe(true);
}
const parameterSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.parameterSchemaRef), "utf8")) as JSONSchemaObject;
const parameters = asset.parameters ?? [];
const parameterNames = parameters.map((parameter) => parameter.name);
const parameterBindings = new Set(extractSQLiteParameterBindings(asset.statement ?? ""));
expect(parameterSchema).toMatchObject({ type: "object", additionalProperties: false });
expect(new Set(parameterNames).size).toBe(parameterNames.length);
expect(sortedValues(parameterSchema.required ?? [])).toEqual(sortedValues(parameterNames));
expect(sortedValues(Object.keys(parameterSchema.properties ?? {}))).toEqual(sortedValues(parameterNames));
for (const parameter of parameters) {
expect(parameter.required).toBe(true);
expect(parameter.binding).toBe(`:${parameter.name}`);
expect(parameterBindings.has(parameter.binding)).toBe(true);
const schemaProperty = parameterSchema.properties?.[parameter.name] ?? {};
if (parameter.enum !== undefined) {
expect(schemaProperty.enum).toEqual(parameter.enum);
if (schemaProperty.type !== undefined) expect(schemaProperty.type).toBe(parameter.type);
} else if (parameter.type !== undefined) {
expect(schemaProperty.type).toEqual(parameter.nullable ? [parameter.type, "null"] : parameter.type);
}
for (const bound of ["minimum", "maximum", "minLength", "maxLength"] as const) {
if (parameter[bound] !== undefined) expect(schemaProperty[bound]).toBe(parameter[bound]);
}
}
expect(sortedValues(parameterBindings)).toEqual(sortedValues(parameters.map((parameter) => parameter.binding)));
if (scumPaginatedQueryKeys.has(query.key)) {
const limit = parameters.find((parameter) => parameter.name === "limit");
const offset = parameters.find((parameter) => parameter.name === "offset");
expect(limit).toMatchObject({ binding: ":limit", type: "integer", required: true, minimum: 1 });
expect(limit?.maximum).toBeLessThanOrEqual(query.maxRows);
expect(offset).toMatchObject({ binding: ":offset", type: "integer", required: true, minimum: 0 });
expect(offset?.maximum).toBeLessThanOrEqual(1000000);
expect(asset.statement).toMatch(/\bLIMIT\s+:limit\b/i);
expect(asset.statement).toMatch(/\bOFFSET\s+:offset\b/i);
}
const resultSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.resultSchemaRef), "utf8")) as { properties?: { rows?: { maxItems?: number } } };
expect(resultSchema.properties?.rows?.maxItems).toBeLessThanOrEqual(asset.safety?.maxRows ?? query.maxRows);
}
});
it("guards SCUM live query asset SQL boundary rules against unsafe input classes", () => {
const rejectedStatements = [
"SELECT 1; SELECT 2",
"INSERT INTO players VALUES (1)",
"UPDATE players SET name = 'x'",
"DELETE FROM players",
"DROP TABLE players",
"ALTER TABLE players ADD COLUMN x INTEGER",
"CREATE TABLE unsafe (id INTEGER)",
"ATTACH DATABASE 'other.db' AS other",
"DETACH DATABASE other",
"SELECT load_extension('unsafe')",
"PRAGMA journal_mode = WAL",
"PRAGMA writable_schema = ON"
];
const allowedReadBoundaries = ["SELECT 1", "WITH one AS (SELECT 1) SELECT * FROM one", "PRAGMA table_info('players')", "PRAGMA foreign_key_list('players')"];
for (const statement of rejectedStatements) expect(statement).toMatch(unsafeSCUMSQLiteReadStatementPattern);
for (const statement of allowedReadBoundaries) expect(statement).not.toMatch(unsafeSCUMSQLiteReadStatementPattern);
});
it("packages SCUM map metadata and tested coordinate transform assets", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration };
const assetPaths = new Set(manifest.assetFiles?.map((file) => file.path) ?? []);
const mapAssets = manifest.scumLiveData.mapAssets ?? [];
expect(mapAssets.map((asset) => asset.key)).toEqual(["scum-current-service-coordinate-map"]);
const mapAsset = mapAssets[0];
expect(mapAsset).toMatchObject({ adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", image: { width: 4096, height: 4096 } });
expect([...assetPaths]).toEqual(expect.arrayContaining([mapAsset.assetPath, mapAsset.transformAssetPath]));
const mapAssetPath = path.join(pluginDir, mapAsset.assetPath);
const transformAssetPath = path.join(pluginDir, mapAsset.transformAssetPath);
expect(`sha256:${crypto.createHash("sha256").update(fs.readFileSync(mapAssetPath)).digest("hex")}`).toBe(mapAsset.digest);
expect(`sha256:${crypto.createHash("sha256").update(fs.readFileSync(transformAssetPath)).digest("hex")}`).toBe(mapAsset.transformDigest);
const metadata = JSON.parse(fs.readFileSync(mapAssetPath, "utf8")) as Record<string, any>;
const metadataSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, mapAsset.metadataSchemaRef), "utf8"));
const validateMetadata = new Ajv2020({ allErrors: true }).compile(metadataSchema);
if (!validateMetadata(metadata)) throw new Error(`SCUM map metadata schema failed: ${JSON.stringify(validateMetadata.errors)}`);
expect(metadata).toMatchObject({ key: mapAsset.key, adapterVersion: mapAsset.adapterVersion, requiredSchemaFingerprint: currentSCUMSchemaFingerprint, transformAssetPath: mapAsset.transformAssetPath, worldBounds: mapAsset.worldBounds, image: mapAsset.image });
expect(metadata.authorization).toMatchObject({ redistribution: "first-party-generated-coordinate-metadata", baseMapArtwork: "not-packaged", renderingAvailability: "unavailable-until-authorized-base-map" });
expect(metadata.layers.map((layer: { key: string; subjectType: string }) => `${layer.key}:${layer.subjectType}`)).toEqual(["players:player", "vehicles:vehicle", "flags:flag"]);
const transform = JSON.parse(fs.readFileSync(transformAssetPath, "utf8")) as SCUMMapTransformAsset;
expect(transform).toMatchObject({ mapAssetKey: mapAsset.key, adapterVersion: mapAsset.adapterVersion, requiredSchemaFingerprint: currentSCUMSchemaFingerprint, worldBounds: mapAsset.worldBounds, image: mapAsset.image, validation: { rejectNonFinite: true, rejectOutOfBounds: true, acceptBoundaryPoints: true } });
expect(transform.fixtures?.map((fixture) => fixture.name)).toEqual(["observed-minimum-corner", "observed-maximum-corner", "observed-center"]);
for (const fixture of transform.fixtures ?? []) {
const projection = projectSCUMMapCoordinate(transform, fixture.world);
expect(projection.ok).toBe(true);
if (projection.ok) {
expect(projection.pixel.x).toBeCloseTo(fixture.pixel.x, 6);
expect(projection.pixel.y).toBeCloseTo(fixture.pixel.y, 6);
}
}
for (const world of [{ x: Number.NaN, y: 0 }, { x: Number.POSITIVE_INFINITY, y: 0 }, { x: 0, y: Number.NEGATIVE_INFINITY }]) expect(projectSCUMMapCoordinate(transform, world)).toEqual({ ok: false, reason: "non-finite" });
for (const world of [{ x: mapAsset.worldBounds.minX - 1, y: mapAsset.worldBounds.minY }, { x: mapAsset.worldBounds.maxX + 1, y: mapAsset.worldBounds.maxY }, { x: mapAsset.worldBounds.minX, y: mapAsset.worldBounds.minY - 1 }, { x: mapAsset.worldBounds.maxX, y: mapAsset.worldBounds.maxY + 1 }]) expect(projectSCUMMapCoordinate(transform, world)).toEqual({ ok: false, reason: "out-of-bounds" });
});
it("rejects SCUM map transform adapter and schema incompatibility", () => {
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
const mapAsset = manifest.scumLiveData.mapAssets[0];
const transformPath = mapAsset.transformAssetPath;
const transform = JSON.parse(fs.readFileSync(path.join(fixtureDir, transformPath), "utf8"));
transform.adapterVersion = "scum-live-data-v2";
transform.requiredSchemaFingerprint = `sha256:${"0".repeat(64)}`;
writeFixtureJSON(fixtureDir, transformPath, transform);
mapAsset.transformDigest = sha256FixtureDigest(fixtureDir, transformPath);
});
expect(errors.some((error) => error.includes("transform adapterVersion must match the map asset declaration"))).toBe(true);
expect(errors.some((error) => error.includes("transform schema fingerprint must match the map asset declaration"))).toBe(true);
});
it("requires SCUM schema probe targets to have generated Run workspace data targets", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.runtimeProfiles.dataTargets = [];
});
expect(errors.some((error) => error.includes("probe.targetKey") && error.includes("runtime data target"))).toBe(true);
});
it("rejects enabling SCUM live-data gates without compatible evidence and immutable digests", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.scumLiveData.capabilityGates[1] = { capability: "players.read", gate: "enabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing", safeReason: "waiting for current service evidence" };
});
expect(errors.some((error) => error.includes("enabled gates require compatible evidence"))).toBe(true);
expect(errors.some((error) => error.includes("enabled gates require a schema fingerprint"))).toBe(true);
expect(errors.some((error) => error.includes("enabled gates require immutable asset digests"))).toBe(true);
});
it("accepts safe SCUM live-data asset declarations without enabling unproven capabilities", () => {
const fingerprint = `sha256:${"f".repeat(64)}`;
const assetPaths = [
"assets/scum-live/login-parser.json",
"assets/scum-live/queries/fixture-players-read.json",
"assets/scum-live/rcon/gift-grant.json",
"assets/scum-live/mutations/profile-xml-patch.json",
"assets/scum-live/map/island.png",
"assets/scum-live/map/transform.json",
"assets/scum-live/gifts/catalog.json"
];
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
manifest.assetFiles = [...manifest.assetFiles, ...assetPaths.map((assetPath) => ({ path: assetPath, mode: 384 }))];
for (const assetPath of [assetPaths[0], assetPaths[1], assetPaths[2], assetPaths[3], assetPaths[6]]) writeFixtureJSON(fixtureDir, assetPath, { packaged: true, assetPath });
const mapBounds = { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 };
const mapImage = { width: 4096, height: 4096 };
writeFixtureJSON(fixtureDir, assetPaths[4], { key: "island-map", mapVersion: "island-map-v1", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, sourceEvidence: "fixture", authorization: { redistribution: "first-party-generated-coordinate-metadata", baseMapArtwork: "not-packaged", renderingAvailability: "unavailable-until-authorized-base-map" }, worldBounds: mapBounds, image: mapImage, layers: [{ key: "players", capability: "positions.read", subjectType: "player", label: "Players", sourceQueryKey: "scum-positions-read" }], transformAssetPath: assetPaths[5] });
writeFixtureJSON(fixtureDir, assetPaths[5], { key: "island-transform", transformVersion: "island-transform-v1", mapAssetKey: "island-map", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, worldBounds: mapBounds, image: mapImage, validation: { rejectNonFinite: true, rejectOutOfBounds: true, acceptBoundaryPoints: true }, fixtures: [{ name: "origin", world: { x: 0, y: 0 }, pixel: { x: 2047.5, y: 2047.5 } }] });
const digest = (assetPath: string) => sha256FixtureDigest(fixtureDir, assetPath);
const giftGrantDigest = digest(assetPaths[2]);
const mutationDigest = digest(assetPaths[3]);
const mapDigest = digest(assetPaths[4]);
const transformDigest = digest(assetPaths[5]);
const catalogDigest = digest(assetPaths[6]);
manifest.scumLiveData = {
...manifest.scumLiveData,
capabilityGates: [
{ capability: "schema-probe", gate: "disabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing", safeReason: "waiting for current service evidence" },
{ capability: "players.read", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [digest(assetPaths[1])], evidenceStatus: "missing", safeReason: "waiting for query execution evidence" },
{ capability: "positions.read", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [mapDigest, transformDigest], evidenceStatus: "missing", safeReason: "waiting for map execution evidence" },
{ capability: "gift-command.write", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [giftGrantDigest, catalogDigest], evidenceStatus: "missing", safeReason: "waiting for gift command evidence" },
{ capability: "profile-xml.write", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [mutationDigest], evidenceStatus: "missing", safeReason: "waiting for mutation safety evidence" }
],
logParsers: [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[0], digest: digest(assetPaths[0]), parserVersion: "login-v1", sourceKey: "scum-login-events", eventType: "scum.login", eventSchemaRef: "schemas/scum-live/login-event.schema.json", maxLineBytes: 4096, cursorPolicy: "source-generation-sequence", privacy: { stripNetworkIdentifiers: true, logicalEventIdentity: "native-or-sanitized-fields" } }],
sqliteQueries: [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[1], digest: digest(assetPaths[1]), capability: "players.read", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", parameterSchemaRef: "schemas/scum-live/players-read.parameters.schema.json", resultSchemaRef: "schemas/scum-live/players-read.result.schema.json", maxRows: 100, timeoutMs: 5000, maxResultBytes: 65536 }],
syncCadences: [{ capability: "players.read", intervalSeconds: 300, jitterPercent: 20, timeoutMs: 5000, maxConcurrentPerServer: 1 }],
typedRconTemplates: [{ key: "gift-grant", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[2], digest: giftGrantDigest, capability: "gift-command.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-management", targetKey: "scum-management", permission: "server.game-client.command", payloadSchemaRef: "schemas/scum-live/gift-grant.payload.schema.json", resultSchemaRef: "schemas/scum-live/gift-grant.result.schema.json", confirmationSchemaRef: "schemas/scum-live/gift-grant.confirmation.schema.json", timeoutMs: 5000, maxPayloadBytes: 4096 }],
guardedMutations: [{ key: "profile-xml-patch", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[3], digest: mutationDigest, capability: "profile-xml.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", permission: "server.game-client.maintenance", payloadSchemaRef: "schemas/scum-live/profile-xml-patch.payload.schema.json", resultSchemaRef: "schemas/scum-live/profile-xml-patch.result.schema.json", confirmationSchemaRef: "schemas/scum-live/profile-xml-patch.confirmation.schema.json", timeoutMs: 10000, maxPayloadBytes: 8192, maxRowsAffected: 1, safety: { requiresExpectedChecksum: true, requiresBackupEvidence: true, requiresOfflineOrMaintenance: true, requiresReadAfterWrite: true } }],
mapAssets: [{ key: "island-map", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[4], digest: mapDigest, requiredSchemaFingerprint: fingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", transformAssetPath: assetPaths[5], transformDigest, worldBounds: { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 }, image: { width: 4096, height: 4096 } }],
giftCatalogs: [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[6], digest: catalogDigest, catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["gift-grant"] }]
};
});
expect(errors).toEqual([]);
});
it("rejects SCUM live-data asset digests that do not match packaged files", () => {
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
const assetPath = "assets/scum-live/login-parser.json";
manifest.assetFiles = [...manifest.assetFiles, { path: assetPath, mode: 384 }];
writeFixtureJSON(fixtureDir, assetPath, { packaged: true });
manifest.scumLiveData.logParsers = [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath, digest: `sha256:${"a".repeat(64)}`, parserVersion: "login-v1", sourceKey: "scum-login-events", eventType: "scum.login", eventSchemaRef: "schemas/scum-live/login-event.schema.json", maxLineBytes: 4096, cursorPolicy: "source-generation-sequence", privacy: { stripNetworkIdentifiers: true, logicalEventIdentity: "native-or-sanitized-fields" } }];
});
expect(errors.some((error) => error.includes("digest does not match packaged asset content"))).toBe(true);
});
it("rejects unsafe SCUM live-data asset declarations", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.scumLiveData.sqliteQueries = [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: "/srv/scum/SCUM.db", digest: "sha256:bad", capability: "players.read", requiredSchemaFingerprint: "sha256:bad", transportKey: "scum-database", targetKey: "scum-database", parameterSchemaRef: "schemas/scum-live/players-read.parameters.schema.json", resultSchemaRef: "schemas/scum-live/players-read.result.schema.json", maxRows: 1000, timeoutMs: 70000, maxResultBytes: 2097152, rawSql: "SELECT * FROM prisoner" }];
manifest.scumLiveData.giftCatalogs = [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: "assets/scum-live/gifts/catalog.json", digest: `sha256:${"2".repeat(64)}`, catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["missing-template"] }];
});
expect(errors.some((error) => error.includes("assetPath") && error.includes("contained package-relative path"))).toBe(true);
expect(errors.some((error) => error.includes("digest") && error.includes("sha256"))).toBe(true);
expect(errors.some((error) => error.includes("must NOT have additional properties") || error.includes("additional properties"))).toBe(true);
expect(errors.some((error) => error.includes("transportTemplateKeys") && error.includes("missing-template"))).toBe(true);
});
it("rejects SCUM typed RCON templates without conclusive confirmation schemas", () => {
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
const assetPath = "assets/scum-live/rcon/unverified-gift.json";
manifest.assetFiles = [...(manifest.assetFiles ?? []), { path: assetPath, mode: 384 }];
writeFixtureJSON(fixtureDir, assetPath, { packaged: true });
writeFixtureJSON(fixtureDir, "schemas/scum-live/unverified-gift.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]));
writeFixtureJSON(fixtureDir, "schemas/scum-live/unverified-gift.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["succeeded", "failed", "unknown"] } }, ["outcome"]));
manifest.scumLiveData.typedRconTemplates = [{ key: "unverified-gift", adapterVersion: "scum-live-data-v1", assetPath, digest: sha256FixtureDigest(fixtureDir, assetPath), capability: "gift-command.write", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, transportKey: "scum-management", targetKey: "scum-management", permission: "server.game-client.command", payloadSchemaRef: "schemas/scum-live/unverified-gift.payload.schema.json", resultSchemaRef: "schemas/scum-live/unverified-gift.result.schema.json", timeoutMs: 5000, maxPayloadBytes: 2048 }];
});
expect(errors.some((error) => error.includes("confirmationSchemaRef"))).toBe(true);
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", () => {
@@ -647,7 +264,6 @@ describe("plugin manifest validation", () => {
runtimeProfiles?: {
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>;
transportProfiles?: Array<{ key?: string; kind?: string; capabilities?: string[] }>;
dataTargets?: Array<Record<string, unknown>>;
};
};
const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
@@ -658,9 +274,6 @@ describe("plugin manifest validation", () => {
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }),
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
]));
expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot", workspaceKey: "databases/scum-database", refreshPolicy: "on-demand-snapshot" })
]));
});
it("defines a generated SCUM companion config without inline proof or session material", () => {
@@ -841,7 +454,7 @@ describe("plugin manifest validation", () => {
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] }>;
};
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
pages: Array<{ key: string; permissions?: string[] }>;
fileWorkspace?: {
defaultDirectoryKey: string;
directories: Array<{ key: string; label: string; scope: string }>;
@@ -878,13 +491,18 @@ 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)).toEqual(["players", "squads", "live-map", "gifts"]);
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.every((page) => !(page.queryTemplateKeys?.length) && !(page.operationKeys?.length))).toBe(true);
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")).toBeUndefined();
expect(manifest.pages.map((page) => page.key)).toEqual(["players", "squads", "live-map", "gifts"]);
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.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.every((page) => !(page.bridgeActions ?? []).includes("remote.access.request"))).toBe(true);
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).toBeUndefined();
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
@@ -963,7 +581,7 @@ describe("plugin manifest validation", () => {
}
});
it("declares bounded SCUM snapshot schemas for local management reads", () => {
it("declares bounded SCUM snapshot schemas for operations projections", () => {
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
gameClientBridge: {
@@ -999,16 +617,7 @@ describe("plugin manifest validation", () => {
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
});
it("rejects the retired SCUM log-projection source in map event schemas", () => {
const schemaDir = path.join(pluginsRoot, "examples/scum-server-plugin/schemas/log-events");
for (const filename of ["player-position.event.schema.json", "vehicle-position.event.schema.json", "player-vehicle-enter.event.schema.json", "player-vehicle-leave.event.schema.json"]) {
const schema = JSON.parse(fs.readFileSync(path.join(schemaDir, filename), "utf8")) as { properties?: { source?: { const?: string; enum?: string[] } } };
expect(schema.properties?.source).toEqual({ const: "companion" });
expect(schema.properties?.source?.enum).toBeUndefined();
}
});
it("keeps SCUM.db query templates absent until current-service evidence exists", () => {
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[];
@@ -1032,7 +641,7 @@ describe("plugin manifest validation", () => {
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
};
const expectedKeys: string[] = [];
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");
@@ -1056,17 +665,17 @@ describe("plugin manifest validation", () => {
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).toBeUndefined();
expect(squadsPage?.queryTemplateKeys).toBeUndefined();
expect(mapPage?.queryTemplateKeys).toBeUndefined();
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).not.toContain("remote.access.request");
expect(pluginPage?.bridgeActions).toContain("remote.access.request");
}
});
it("keeps SCUM RCON operation templates absent until command confirmation is verified", () => {
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: {
@@ -1075,7 +684,7 @@ describe("plugin manifest validation", () => {
};
pages: Array<{ key: string; permissions?: string[] }>;
};
const expectedKeys: 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) {
@@ -1094,8 +703,8 @@ describe("plugin manifest validation", () => {
}
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts");
expect(playersPage?.operationKeys).toBeUndefined();
expect(giftsPage?.operationKeys).toBeUndefined();
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");
});