Revert SCUM real data management change
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
export const scumMigrationParityFixtures = {
|
||||
configuration: {
|
||||
source: { id: "config-1", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: 64, hostPath: "/srv/scum", RconPassword: "not-migrated" } },
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "config-1", recordedAt: "2026-07-29T00:00:00Z", payload: { observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: "64" } } }
|
||||
},
|
||||
playerHistory: {
|
||||
source: { updatedAt: "2026-07-29T00:10:00Z", player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", online: true }, sessions: [{ id: "session-1", startedAt: "2026-07-29T00:01:00Z", endedAt: "2026-07-29T00:08:00Z", networkFingerprint: "not-migrated" }], accessAttempts: [{ occurredAt: "2026-07-29T00:02:00Z", outcome: "review", reason: "manual review", networkCorrelationKey: "not-migrated" }], securitySignals: [{ lastObservedAt: "2026-07-29T00:03:00Z", ruleKey: "repeat-access", summary: "manual review", evidenceCount: 2 }] },
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "player-1", recordedAt: "2026-07-29T00:10:00Z", payload: { player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", status: "online" }, sessions: [{ id: "session-1", playerId: "player-1", kind: "logout", occurredAt: "2026-07-29T00:08:00Z" }], risks: [{ kind: "review", level: "medium", observedAt: "2026-07-29T00:02:00Z", summary: "manual review" }, { kind: "repeat-access", level: "medium", observedAt: "2026-07-29T00:03:00Z", summary: "manual review" }] } }
|
||||
},
|
||||
gift: {
|
||||
source: { id: "gift-1", revisionId: "revision-1", gamePlayerRecordId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z", deliveryCommandId: "not-migrated" },
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "gift-1", recordedAt: "2026-07-29T00:20:00Z", payload: { id: "gift-1", revisionId: "revision-1", playerId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z" } }
|
||||
},
|
||||
statePatch: {
|
||||
source: { id: "patch-1", gamePlayerRecordId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "execution-unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }], bridgeCommandId: "not-migrated" },
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } }
|
||||
},
|
||||
trajectory: {
|
||||
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } }
|
||||
}
|
||||
} as const;
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -3,35 +3,172 @@ import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
|
||||
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
|
||||
import { configurationCatalog, validateConfigPatch } from "../examples/scum-server-plugin/features/schemas.js";
|
||||
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
|
||||
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
|
||||
|
||||
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
|
||||
const projectionData = {
|
||||
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" }, unknownFields: { "855": 100 } }],
|
||||
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }],
|
||||
members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }],
|
||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
|
||||
flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
|
||||
positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }],
|
||||
operations: [{ id: "op-1", templateKey: "player.fame.set", status: "waiting", safeSummary: { message: "awaiting approval" } }],
|
||||
workflows: [{ id: "wf-1", templateKey: "scum.world-refresh", status: "queued", currentStepKey: "read-positions", createdAt: "2026-08-10T00:00:00Z", safeSummary: { message: "world refresh queued" } }],
|
||||
steps: [{ stepKey: "read-positions", status: "queued", capability: "remote.run.db.sqlite.query", safeSummary: { message: "queued safely" } }]
|
||||
};
|
||||
|
||||
describe("SCUM plugin feature module", () => {
|
||||
it("owns runtime allowlists without a version gate", () => {
|
||||
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
|
||||
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围");
|
||||
expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull();
|
||||
expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("白名单");
|
||||
expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]);
|
||||
expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
|
||||
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
|
||||
expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("受控目录");
|
||||
});
|
||||
|
||||
it("keeps the five management pages free of legacy snapshot reads and fake controls", () => {
|
||||
for (const pageKey of ["players", "squads", "live-map", "gifts", "workflows"]) {
|
||||
const view = renderAndCollect(pageKey);
|
||||
expect(view.texts.join("\n")).toContain("不会读取遗留快照");
|
||||
}
|
||||
for (const removed of ["listSCUM", "map-local-board", "Fame +100", "starter-pack", "855", "Workflow", "投影"]) expect(pageSource).not.toContain(removed);
|
||||
it("maps transitional records only as read-only provenance", () => {
|
||||
expect(migratePlayerRecord({ id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" })).toMatchObject({ provenance: "transitional-read-only", readOnly: true, payload: { gamePlayerId: "steam-1" } });
|
||||
expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] });
|
||||
});
|
||||
|
||||
it("falls legacy routes back to user management", () => {
|
||||
const view = renderAndCollect("workflows");
|
||||
it("preserves only allowlisted transitional history for every feature area", () => {
|
||||
expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } });
|
||||
expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } });
|
||||
expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true });
|
||||
expect(migrateStatePatchRecord({ id: "patch-1", gamePlayerRecordId: "p-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", status: "confirmed", createdAt: "2026-07-29T00:00:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] })).toMatchObject({ payload: { status: "succeeded" }, readOnly: true });
|
||||
expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true });
|
||||
});
|
||||
|
||||
it("matches controlled transitional fixtures without carrying sensitive fields into plugin history", () => {
|
||||
expect(migrateConfigurationRecord(scumMigrationParityFixtures.configuration.source)).toEqual(scumMigrationParityFixtures.configuration.expected);
|
||||
expect(migratePlayerProfileRecord(scumMigrationParityFixtures.playerHistory.source)).toEqual(scumMigrationParityFixtures.playerHistory.expected);
|
||||
expect(migrateGiftGrantRecord(scumMigrationParityFixtures.gift.source)).toEqual(scumMigrationParityFixtures.gift.expected);
|
||||
expect(migrateStatePatchRecord(scumMigrationParityFixtures.statePatch.source)).toEqual(scumMigrationParityFixtures.statePatch.expected);
|
||||
expect(migrateTrajectoryHistoryRecord(scumMigrationParityFixtures.trajectory.source)).toEqual(scumMigrationParityFixtures.trajectory.expected);
|
||||
expect(migrateConfigurationRecord({ version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { hostPath: "/srv/scum" } })).toBeNull();
|
||||
});
|
||||
|
||||
it("enables plugin authority only for one exact server-feature flag", () => {
|
||||
const flags = [{ serverInstanceId: "server-1", feature: "configuration" as const, authority: "plugin" as const }];
|
||||
expect(migrationStatus(flags, "server-1", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true });
|
||||
expect(migrationStatus(flags, "server-2", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
|
||||
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
|
||||
});
|
||||
|
||||
it("renders projection-backed user management without raw file/config panels", () => {
|
||||
const view = renderAndCollect();
|
||||
expect(view.nodes).toContain("section:用户管理");
|
||||
expect(view.texts.join("\n")).toContain("登录日志和 SCUM.db typed observations");
|
||||
expect(view.texts).toContain("投影/Companion 可用");
|
||||
expect(view.texts).toContain("刷新投影");
|
||||
expect(view.texts).toContain("刷新真实数据");
|
||||
expect(view.texts).toContain("Mira");
|
||||
expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
|
||||
expect(view.texts.join("\n")).toContain("Profile profile-1");
|
||||
expect(view.texts.join("\n")).toContain("Fame 42");
|
||||
expect(view.buttons.find((button) => button.label === "Fame +100")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "现金 +1000")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "855 审批")?.disabled).toBe(false);
|
||||
for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText);
|
||||
});
|
||||
|
||||
it("does not invent fake players when projections are empty", () => {
|
||||
const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } });
|
||||
expect(view.texts.join("\n")).toContain("暂无玩家投影");
|
||||
expect(view.texts.join("\n")).toContain("不会显示假玩家");
|
||||
expect(view.texts).not.toContain("Mira");
|
||||
expect(pageSource).not.toContain("fallbackFiles");
|
||||
expect(pageSource).not.toContain("samplePlayers");
|
||||
});
|
||||
|
||||
it("renders squad and flag governance from projections", () => {
|
||||
const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
|
||||
expect(view.nodes).toContain("section:队伍管理");
|
||||
expect(view.texts).toContain("队伍");
|
||||
expect(view.texts).toContain("成员 / 旗帜");
|
||||
expect(view.texts).toContain("Wolves");
|
||||
expect(view.texts.join("\n")).toContain("成员 3");
|
||||
expect(view.texts.join("\n")).toContain("verified");
|
||||
});
|
||||
|
||||
it("renders realtime map overlays without sample coordinates", () => {
|
||||
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
|
||||
expect(view.nodes).toContain("section:实时地图");
|
||||
expect(view.texts).toContain("地图覆盖物");
|
||||
expect(view.texts.join("\n")).toContain("坐标点");
|
||||
expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3");
|
||||
expect(pageSource).toContain("map-projection-board");
|
||||
expect(pageSource).not.toContain("sampleCoordinates");
|
||||
});
|
||||
|
||||
it("renders gift and workflow typed status surfaces", () => {
|
||||
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
|
||||
expect(gifts.nodes).toContain("section:礼包管理");
|
||||
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
|
||||
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
|
||||
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
|
||||
|
||||
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
|
||||
expect(workflows.nodes).toContain("section:Workflow 状态");
|
||||
expect(workflows.texts.join("\n")).toContain("scum.world-refresh");
|
||||
expect(workflows.texts.join("\n")).toContain("read-positions");
|
||||
});
|
||||
|
||||
it("loads projections through typed workspace actions instead of file snapshots", () => {
|
||||
expect(pageSource).toContain("listSCUMPlayers");
|
||||
expect(pageSource).toContain("createSCUMOperation");
|
||||
expect(pageSource).toContain("createSCUMWorkflow");
|
||||
expect(pageSource).not.toContain("getFileSnapshot");
|
||||
expect(pageSource).not.toContain("requestFile");
|
||||
expect(pageSource).not.toContain("writeFile");
|
||||
expect(pageSource).not.toContain("setInterval");
|
||||
});
|
||||
});
|
||||
|
||||
function renderAndCollect(pageKey: string) {
|
||||
const nodes: string[] = []; const texts: string[] = [];
|
||||
const collect = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collect); else if (value && typeof value === "object" && "children" in value) collect((value as { children?: unknown }).children); };
|
||||
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); children.forEach(collect); return { type, props, children }; } };
|
||||
renderPluginPage(react, { page: { key: pageKey, title: pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : "用户管理" }, context: { serverInstanceId: "server-1", permissions: ["server.game-client.read"] }, availability: { available: false, reason: "等待当前服务器完成兼容性验证。" } });
|
||||
return { nodes, texts };
|
||||
function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) {
|
||||
const nodes: string[] = [];
|
||||
const texts: string[] = [];
|
||||
const buttons: Array<{ label: string; disabled: boolean }> = [];
|
||||
const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); };
|
||||
let stateCall = 0;
|
||||
const react = {
|
||||
createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => {
|
||||
if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`);
|
||||
children.forEach(collectText);
|
||||
if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) });
|
||||
return { type, props, children };
|
||||
},
|
||||
useEffect: () => undefined,
|
||||
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
|
||||
stateCall += 1;
|
||||
if (stateCall === 1) return [{ status: "ready", data: options.data ?? projectionData } as T, () => undefined];
|
||||
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
|
||||
}
|
||||
};
|
||||
renderPluginPage(react, {
|
||||
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] },
|
||||
availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
|
||||
workspaceActions: {
|
||||
listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }),
|
||||
listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }),
|
||||
listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }),
|
||||
listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }),
|
||||
listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }),
|
||||
listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }),
|
||||
listSCUMOperations: async () => ({ items: projectionData.operations, count: projectionData.operations.length }),
|
||||
listSCUMWorkflows: async () => ({ items: projectionData.workflows, count: projectionData.workflows.length }),
|
||||
listSCUMWorkflowSteps: async () => ({ items: projectionData.steps, count: projectionData.steps.length }),
|
||||
createSCUMOperation: async () => ({ id: "op-new", status: "waiting" }),
|
||||
createSCUMWorkflow: async () => ({ id: "wf-new", status: "queued" })
|
||||
}
|
||||
});
|
||||
return { nodes, texts, buttons };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user